@hublo/sentinel 0.1.0-alpha.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.
- package/LICENSE +21 -0
- package/README.md +276 -0
- package/dist/bin/sentinel.d.ts +1 -0
- package/dist/bin/sentinel.js +289 -0
- package/dist/bin/sentinel.js.map +1 -0
- package/dist/chunk-D6QBEHMF.js +609 -0
- package/dist/chunk-D6QBEHMF.js.map +1 -0
- package/dist/index.d.ts +243 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/tsconfig/nest.json +26 -0
- package/dist/tsconfig/node.json +23 -0
- package/dist/tsconfig/react.json +27 -0
- package/package.json +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hublo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# @hublo/sentinel
|
|
2
|
+
|
|
3
|
+
> One CLI that guards code health across Hublo repos: shared lint/typescript/build/test presets, static & dynamic analysis, and architecture checks, behind a single command.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
> Status: **scaffold / design**. This README is the design reference; tools are implemented one at a time on top of this foundation.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
A large monorepo accumulates:
|
|
14
|
+
|
|
15
|
+
- **Config sprawl** — the Hublo monorepo has ~116 eslint configs, ~528 tsconfig files, per-project vite/vitest configs. Almost all just re-extend a shared base, but the base drifts and every change means touching many files.
|
|
16
|
+
- **Centralized, all-or-nothing dependencies** — ~165 devDependencies at the root, ~93% of projects declare none of their own. Bumping a tool is a "big bang": all projects, at once, untested in isolation.
|
|
17
|
+
|
|
18
|
+
`sentinel` fixes both: **one versioned source of truth** for tooling + config, adopted **per module** so you can migrate gradually and bump the whole toolchain atomically.
|
|
19
|
+
|
|
20
|
+
## What it gives you
|
|
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.
|
|
23
|
+
|
|
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
|
+
- **One source of truth for tooling dependencies** — a project depends on `@hublo/sentinel`, not on a scattered pile of eslint / vitest / plugin devDeps. Bump one version and the whole toolchain moves, atomically, tested in isolation first.
|
|
26
|
+
- **`--update` = adopt, refresh, _and_ migrate** — the same command generates the stubs the first time (**adopt**), regenerates them after a change like a runner swap (**refresh**), and is run module by module to roll out gradually (**migrate**).
|
|
27
|
+
- **Move one module at a time** — installed per module, so you migrate at your pace; a module can adopt sentinel while its neighbour keeps the old setup. No big-bang.
|
|
28
|
+
- **Swap tools without touching projects** — change eslint → biome (or benchmark them) in one place; `--update` regenerates the stubs.
|
|
29
|
+
- **No silent drift** — the guard keeps every project's config converged on the source of truth.
|
|
30
|
+
|
|
31
|
+
### Before → after
|
|
32
|
+
|
|
33
|
+
| | Before | After |
|
|
34
|
+
| ------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
35
|
+
| **Config** | ~116 eslint + ~528 tsconfig files with real, drifting content | thin stubs that `extends` a versioned preset; rules in one place |
|
|
36
|
+
| **Tooling deps** | ~165 devDeps at the root, shared by all | one `@hublo/sentinel` per module; the toolchain rides its version |
|
|
37
|
+
| **Upgrade a tool** | big-bang: every project at once, untested in isolation | bump one version, tested in sentinel first, atomic |
|
|
38
|
+
| **Swap a tool** | edit config in every project | swap an adapter + `--update`; zero project churn |
|
|
39
|
+
| **A rule change** | edit many configs, hope they stay consistent | change once; the drift guard enforces it |
|
|
40
|
+
| **Migration** | all-or-nothing | module by module, at your pace |
|
|
41
|
+
|
|
42
|
+
## How you use it
|
|
43
|
+
|
|
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
|
+
|
|
46
|
+
**Step 1 — put a module on sentinel** (once per module, by a dev; the files are committed):
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pnpm add -D @hublo/sentinel
|
|
50
|
+
sentinel --update # run from the app dir; writes eslint.config.js, tsconfig, ... then you commit them
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Those files are tiny, they just point at a sentinel preset. What gets committed:
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
// eslint.config.js — generated; overrides go through the sentinel allowlist, not inline
|
|
57
|
+
import react from '@hublo/sentinel/lint/react'
|
|
58
|
+
export default react
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```jsonc
|
|
62
|
+
// tsconfig.json — extends the preset; only project-specific paths/include stay local
|
|
63
|
+
{
|
|
64
|
+
"extends": "@hublo/sentinel/typescript/react",
|
|
65
|
+
"compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } },
|
|
66
|
+
"include": ["src"]
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
And the app's `package.json` scripts route every check through the one CLI (run from the app dir, sentinel scopes to it):
|
|
71
|
+
|
|
72
|
+
```jsonc
|
|
73
|
+
// package.json
|
|
74
|
+
{
|
|
75
|
+
"scripts": {
|
|
76
|
+
"lint": "sentinel --run --lint",
|
|
77
|
+
"lint:fix": "sentinel --run --lint --fix",
|
|
78
|
+
"typecheck": "sentinel --run --typescript",
|
|
79
|
+
"test": "sentinel --run --test"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Step 2 — from then on you rarely touch sentinel.** The committed config files do the work:
|
|
85
|
+
|
|
86
|
+
- your **editor** reads them → live lint / type / format, exactly as before;
|
|
87
|
+
- **CI** runs the checks: `sentinel --run --lint` from the module dir (or `nx run my-app:lint`, or even `eslint` directly, the generated config is self-sufficient);
|
|
88
|
+
- the rules always come from the sentinel version the stubs point at.
|
|
89
|
+
|
|
90
|
+
**Step 3 — evolve the toolchain, in one central place:**
|
|
91
|
+
|
|
92
|
+
- a **rule change** → bump the `@hublo/sentinel` version; the stubs already point at it, so there is **nothing to regenerate**;
|
|
93
|
+
- a **structural change** (new tool, new preset, runner swap) → run `sentinel --update` once to refresh the stubs (sentinel tells you when this is needed).
|
|
94
|
+
|
|
95
|
+
**Step 4 — stay converged:** a drift guard in CI flags any module whose config quietly diverged from the shared source.
|
|
96
|
+
|
|
97
|
+
The per-tool knowledge (eslint → `eslint.config.js`, tsc → `tsconfig`, …) lives **inside sentinel as an adapter**, swappable centrally, but never a runtime dependency of the project.
|
|
98
|
+
|
|
99
|
+
## Architecture: `target → runner → flavour`
|
|
100
|
+
|
|
101
|
+
Every check is described by three layers:
|
|
102
|
+
|
|
103
|
+
| Layer | Flag | What it is | Examples |
|
|
104
|
+
| -------------------- | --------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
105
|
+
| **target** (role) | `--lint`, `--typescript`, … | the _kind_ of check, stable | `lint` `format` `typescript` `build` `test` `static-analysis` `runtime-analysis` |
|
|
106
|
+
| **runner** (adapter) | `--runner=<tool>` | the _tool_ behind the target, swappable | lint: `eslint`/`biome`/`oxlint` · types: `tsc`/`tsgo` · build: `vite` · test: `vitest` |
|
|
107
|
+
| **flavour** (preset) | declared | the _variant_ per stack (strict by default) | `react` `nest` `svelte` `node` |
|
|
108
|
+
|
|
109
|
+
A run is `target × runner × flavour`, e.g. `sentinel --run --lint --runner=eslint` from the `host-admin` dir.
|
|
110
|
+
|
|
111
|
+
- `--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).
|
|
112
|
+
- The **flavour is declared, never detected.** Once a project is on sentinel, its committed config already says which preset (`extends '@hublo/sentinel/lint/react'`), so `--run`/`--report` don't need a flag, the tool reads that config. `--update`/`--inspect` take it explicitly with `--flavour` (the first `--update` is where you declare it). sentinel never guesses the stack, so a Nest app can't be silently treated as React.
|
|
113
|
+
|
|
114
|
+
### Adapters & the engine (ports & adapters)
|
|
115
|
+
|
|
116
|
+
`sentinel` is a small **ports & adapters** design, which is what keeps tool logic out of the core:
|
|
117
|
+
|
|
118
|
+
- 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;
|
|
119
|
+
- 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`);
|
|
120
|
+
- 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.
|
|
121
|
+
|
|
122
|
+
`sentinel` does not reimplement tools. The contract:
|
|
123
|
+
|
|
124
|
+
- **`appliesTo(flavour)`** — which flavours this adapter handles (resolution filters on it, so a React-only adapter is never picked for Nest)
|
|
125
|
+
- **`plan(flavour)`** — PURE: returns a declarative `UpdatePlan` of file operations (used by `--update`). The adapter never touches the disk; the engine applies the plan.
|
|
126
|
+
- **`run(ctx)`** — invoke the tool's bin against the project (used by `--run`)
|
|
127
|
+
- **`inspect(flavour)`** — the adapter's resolved base config (used by `--inspect`)
|
|
128
|
+
- **`report(ctx)`** — metrics (used by `--report`)
|
|
129
|
+
|
|
130
|
+
**`--update` is a declarative plan, not file-writing inside the adapter.** The adapter describes intent as operations; the engine executes them:
|
|
131
|
+
|
|
132
|
+
- `write { path, contents }` — a file the adapter fully owns (the thin stub)
|
|
133
|
+
- `merge-json { path, value }` — pin the keys sentinel owns while **preserving** a project's own (this is how a tsconfig's `paths`/`include` survive)
|
|
134
|
+
- `ensure-lines { path, lines }` — idempotently add lines (e.g. an import into an existing test setup)
|
|
135
|
+
|
|
136
|
+
So the tool-_meaning_ lives in the adapter and the read/merge/write _mechanics_ live in the engine, which keeps adapters pure and decoupled from where files live.
|
|
137
|
+
|
|
138
|
+
**Adding a new tool = writing one adapter** plus one line in the bootstrap (`src/adapters.ts`). The CLI (verbs × targets) never changes: swap a tool = swap an adapter (one place); benchmark = run two adapters on the same target.
|
|
139
|
+
|
|
140
|
+
## Schema
|
|
141
|
+
|
|
142
|
+
**How a command flows** (the CLI stays generic; only adapters are tool-specific):
|
|
143
|
+
|
|
144
|
+
```mermaid
|
|
145
|
+
flowchart LR
|
|
146
|
+
CLI["sentinel --verb --target<br/>[--runner]"] --> D[dispatch]
|
|
147
|
+
D --> R["registry.resolve<br/>(target, flavour, runner)"]
|
|
148
|
+
R --> A["adapter<br/>eslint / tsc / vitest / ..."]
|
|
149
|
+
A -->|"--run"| Run["tool binary on the project"]
|
|
150
|
+
A -->|"--update"| Upd["declarative plan → engine writes"]
|
|
151
|
+
A -->|"--inspect"| Ins["resolved config"]
|
|
152
|
+
A -->|"--report"| Rep["metrics"]
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**How config lives** (one source of truth in sentinel; thin generated stubs keep the editor and nx working):
|
|
156
|
+
|
|
157
|
+
```mermaid
|
|
158
|
+
flowchart TB
|
|
159
|
+
S["@hublo/sentinel<br/>rules = one source of truth"]
|
|
160
|
+
S -->|"--update generates"| Stub["thin stub per module<br/>(extends sentinel)"]
|
|
161
|
+
Stub --> IDE["editor: live lint / type / format"]
|
|
162
|
+
Stub --> NX["nx: target inference"]
|
|
163
|
+
S -->|"--run injects config"| CI["CLI / CI: run tool on target"]
|
|
164
|
+
Guard["drift guard + allowlist"] -. "validates the stub stays thin" .-> Stub
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## How config lives (the model)
|
|
168
|
+
|
|
169
|
+
The rules live in `sentinel`. Each module keeps a **thin, generated stub** per tool, a few lines that `extends`/re-export the sentinel preset:
|
|
170
|
+
|
|
171
|
+
- **Rules in sentinel** — one source of truth, versioned.
|
|
172
|
+
- **Thin stubs per module** — the stub is what keeps the **editor working** (VS Code discovers config by file, real-time lint/type/format stay live) and what **nx** uses to infer targets. Stubs are **generated by `sentinel --update`**, never hand-written; swapping a runner regenerates them.
|
|
173
|
+
- **Drift guard** — sentinel validates that each stub is _only_ the sanctioned `extends`, with nothing added or overridden. Unsanctioned drift is flagged in CI; a genuine exception must be **declared in an allowlist** (visible, reviewed), never silent.
|
|
174
|
+
- **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.
|
|
175
|
+
- **Runner binaries** (`eslint`, `typescript`, `vite`, `vitest`, …) are **peer dependencies** so they install once and stay resolvable by the editor + nx, while sentinel still dictates their versions.
|
|
176
|
+
|
|
177
|
+
## CLI
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
sentinel <verb> <target> [options]
|
|
181
|
+
|
|
182
|
+
VERBS --run execute the target's tool
|
|
183
|
+
--inspect show the resolved configuration
|
|
184
|
+
--update generate/apply the config stubs
|
|
185
|
+
--report metrics and health
|
|
186
|
+
|
|
187
|
+
TARGETS --lint --format --typescript --build --test
|
|
188
|
+
--static-analysis --runtime-analysis --arch --all
|
|
189
|
+
|
|
190
|
+
OPTIONS --module <name> scope to a module (planned; run sentinel from the module dir for now)
|
|
191
|
+
--flavour <name> stack preset, declared not detected (react, nest, ...)
|
|
192
|
+
--runner <tool> override the default runner
|
|
193
|
+
--ci non-zero exit on failure
|
|
194
|
+
--fix auto-fix where applicable
|
|
195
|
+
|
|
196
|
+
EXAMPLES (run from the app directory)
|
|
197
|
+
sentinel --run --typescript
|
|
198
|
+
sentinel --update --lint --flavour react
|
|
199
|
+
sentinel --run --lint --runner=oxlint
|
|
200
|
+
sentinel --report --all --ci
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Repository layout
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
bin/sentinel.ts # CLI entry (parse verb × target × runner; never lists tools)
|
|
207
|
+
src/
|
|
208
|
+
adapters.ts # bootstrap: the one place adapters are wired in
|
|
209
|
+
core/
|
|
210
|
+
types.ts # the adapter contract, pure types (Adapter, FileOperation, UpdatePlan)
|
|
211
|
+
base-adapter.ts # optional convenience base class for adapters
|
|
212
|
+
domain.ts # vocabulary + derived types (verbs, targets, flavours)
|
|
213
|
+
settings.ts # tunables (workspace-root marker, ...)
|
|
214
|
+
registry.ts # register + flavour-aware resolve
|
|
215
|
+
dispatch.ts # verb → adapter method
|
|
216
|
+
apply-plan.ts # the engine's filesystem port (applies --update operations)
|
|
217
|
+
roles/<config-role>/ # lint, format, typescript, build, test
|
|
218
|
+
adapters/<runner>/ # one adapter per tool (implements the contract)
|
|
219
|
+
flavours/<stack>/ # config presets per stack (react, nest, svelte, ...)
|
|
220
|
+
roles/{static-analysis,runtime-analysis}/ # analysis roles
|
|
221
|
+
configs/ # fixed configs (internal, not exported)
|
|
222
|
+
runners/ # one runner per sub-tool (duplication, complexity, ...)
|
|
223
|
+
shared/ # reusable utils (package-json, deep-merge, text)
|
|
224
|
+
tests/ # unit tests + tests/e2e (runs the built dist binary)
|
|
225
|
+
.changeset/ # release notes
|
|
226
|
+
.github/workflows/ # ci.yml (PR checks) + release.yml (changesets publish)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Subpath exports (in `package.json`) expose presets to consumers, e.g. `@hublo/sentinel/lint/react`, `@hublo/sentinel/typescript/nest`.
|
|
230
|
+
|
|
231
|
+
## FAQ
|
|
232
|
+
|
|
233
|
+
**Is every tool call proxied through sentinel?**
|
|
234
|
+
No, and it's not a runtime proxy. sentinel manages the **widespread, config-driven tools** (eslint, tsc, vitest, …); something like **nx doesn't need it** at all, it's the task runner, it just runs the scripts. And for the tools it does manage, the config files are **standard**, so they and your editor run **natively**, `sentinel --run` is a convenience dispatcher and you can run `eslint .` directly.
|
|
235
|
+
|
|
236
|
+
**What does running eslint look like after sentinel?**
|
|
237
|
+
Exactly like before. `eslint` reads `eslint.config.js`, which imports a sentinel preset. `sentinel --run --lint`, `nx run app:lint`, and `eslint .` all run the same eslint with the same rules.
|
|
238
|
+
|
|
239
|
+
**Who decides which tool `--lint` runs?**
|
|
240
|
+
The target's default runner, configured in sentinel (e.g. `lint → eslint`). Override per run with `--runner=biome`. So `--lint` is the _what_; the runner is the _how_, swappable in one place.
|
|
241
|
+
|
|
242
|
+
**Can two different lint tools coexist in the repo?**
|
|
243
|
+
Yes. Per app (app A defaults to eslint, app B to biome), or even in the same app: `sentinel --run --lint --runner=oxlint` (fast) alongside `--runner=eslint` (full). The registry holds every runner for a target; the default is just the common case.
|
|
244
|
+
|
|
245
|
+
**What about tools that can't `extends` / compose config?**
|
|
246
|
+
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).
|
|
247
|
+
|
|
248
|
+
**What does an nx `project.json` look like?**
|
|
249
|
+
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.
|
|
250
|
+
|
|
251
|
+
## Roadmap
|
|
252
|
+
|
|
253
|
+
This scaffold is the foundation; each tool is added one at a time on top of it:
|
|
254
|
+
|
|
255
|
+
1. **Foundation** — repo, exports, CLI skeleton, adapter contract, changesets, CI/release. _(this scaffold)_
|
|
256
|
+
2. **TypeScript** (`--typescript`, CI gate) — runner `tsc`, later `tsgo`.
|
|
257
|
+
3. **Lint** (`--lint`) — benchmark `eslint` vs `biome` vs `oxlint`.
|
|
258
|
+
4. **Build** (`--build`) — `vite`.
|
|
259
|
+
5. **Test** (`--test`) — `vitest`, plus a11y / w3c setups.
|
|
260
|
+
6. **Static analysis** (`--static-analysis`) — cycles, complexity, duplication, centrality.
|
|
261
|
+
7. **Runtime analysis** (`--runtime-analysis`) — bundle, Lighthouse, web vitals.
|
|
262
|
+
8. **Unified CI workflow + `--report --all` dashboard.**
|
|
263
|
+
|
|
264
|
+
## Open decisions & risks being validated
|
|
265
|
+
|
|
266
|
+
- **Registry:** **GitHub Packages (private)** for the foundation, publishing needs no extra secret and there is no consumer to authenticate yet. Public npm under `@hublo` (zero consumer auth, OSS-ready) stays an option once the package is stable; the consuming repo's `.npmrc`/auth is set up with the first tool ticket, when there is actually something to install.
|
|
267
|
+
- **Performance:** prefer adopting Rust-native tools (`oxlint`/`biome`, `tsgo`) over hand-written Rust+WASM; reserve custom WASM for a _measured_ hot path only.
|
|
268
|
+
- **Validation spike (before wide rollout):** wire one real module to sentinel and confirm on real pnpm layout that (1) the editor keeps live lint/type/format, (2) runner binaries + plugins resolve per module, (3) nx target inference survives a runner swap, (4) old-config and sentinel modules coexist during migration.
|
|
269
|
+
|
|
270
|
+
## Contributing
|
|
271
|
+
|
|
272
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) for how to add a tool adapter, from design to test.
|
|
273
|
+
|
|
274
|
+
## License
|
|
275
|
+
|
|
276
|
+
[MIT](./LICENSE) © Hublo
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
detectFramework,
|
|
4
|
+
dispatch,
|
|
5
|
+
readOwnVersion,
|
|
6
|
+
readProjectPackageJson,
|
|
7
|
+
registerAdapters,
|
|
8
|
+
resolve,
|
|
9
|
+
resolveBin
|
|
10
|
+
} from "../chunk-D6QBEHMF.js";
|
|
11
|
+
|
|
12
|
+
// bin/sentinel.ts
|
|
13
|
+
import { existsSync } from "fs";
|
|
14
|
+
import { basename, join as join2 } from "path";
|
|
15
|
+
import { program } from "commander";
|
|
16
|
+
|
|
17
|
+
// src/core/discover-modules.ts
|
|
18
|
+
import { execFileSync } from "child_process";
|
|
19
|
+
import { mkdtempSync, readFileSync, rmSync } from "fs";
|
|
20
|
+
import { tmpdir } from "os";
|
|
21
|
+
import { join } from "path";
|
|
22
|
+
function runNx(cwd2, args) {
|
|
23
|
+
const nx = resolveBin(cwd2, "nx") ?? "nx";
|
|
24
|
+
try {
|
|
25
|
+
return execFileSync(nx, args, {
|
|
26
|
+
cwd: cwd2,
|
|
27
|
+
encoding: "utf8",
|
|
28
|
+
env: { ...process.env, NX_DAEMON: "false" }
|
|
29
|
+
});
|
|
30
|
+
} catch (error) {
|
|
31
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
32
|
+
throw new Error(
|
|
33
|
+
`sentinel: could not run nx (${message}). Is nx installed in this workspace, and are you at its root?`,
|
|
34
|
+
{ cause: error }
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function readGraph(cwd2) {
|
|
39
|
+
const dir = mkdtempSync(join(tmpdir(), "sentinel-nx-"));
|
|
40
|
+
const file = join(dir, "graph.json");
|
|
41
|
+
try {
|
|
42
|
+
runNx(cwd2, ["graph", "--file", file]);
|
|
43
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
44
|
+
const nodes = parsed.graph?.nodes;
|
|
45
|
+
if (!nodes || typeof nodes !== "object") {
|
|
46
|
+
throw new Error(
|
|
47
|
+
"sentinel: unexpected nx graph output (no graph.nodes); the installed nx version may be incompatible."
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return Object.entries(nodes).map(([name, node]) => ({
|
|
51
|
+
name,
|
|
52
|
+
root: join(cwd2, node.data.root)
|
|
53
|
+
}));
|
|
54
|
+
} finally {
|
|
55
|
+
rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function discoverModules(cwd2, options = {}) {
|
|
59
|
+
const modules = readGraph(cwd2);
|
|
60
|
+
if (!options.affected) return modules;
|
|
61
|
+
const affected = new Set(
|
|
62
|
+
JSON.parse(runNx(cwd2, ["show", "projects", "--affected", "--json"]))
|
|
63
|
+
);
|
|
64
|
+
return modules.filter((module) => affected.has(module.name));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/core/domain.ts
|
|
68
|
+
var VERBS = ["run", "inspect", "update", "report"];
|
|
69
|
+
var TARGETS = [
|
|
70
|
+
"lint",
|
|
71
|
+
"format",
|
|
72
|
+
"typescript",
|
|
73
|
+
"build",
|
|
74
|
+
"test",
|
|
75
|
+
"static-analysis",
|
|
76
|
+
"runtime-analysis",
|
|
77
|
+
"arch"
|
|
78
|
+
];
|
|
79
|
+
var FLAVOURS = ["react", "nest", "svelte", "node"];
|
|
80
|
+
|
|
81
|
+
// src/core/orchestrate.ts
|
|
82
|
+
async function analyse(params) {
|
|
83
|
+
const results = [];
|
|
84
|
+
let worstCode = 0;
|
|
85
|
+
let done = 0;
|
|
86
|
+
for (const module of params.modules) {
|
|
87
|
+
const flavour2 = detectFramework(readProjectPackageJson(module.root));
|
|
88
|
+
const ctx = {
|
|
89
|
+
module: module.name,
|
|
90
|
+
cwd: module.root,
|
|
91
|
+
flavour: flavour2,
|
|
92
|
+
ci: params.ci,
|
|
93
|
+
fix: params.fix
|
|
94
|
+
};
|
|
95
|
+
for (const target of params.targets) {
|
|
96
|
+
let adapter;
|
|
97
|
+
try {
|
|
98
|
+
adapter = resolve(target, flavour2, params.runner);
|
|
99
|
+
} catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
if (params.verb === "report") {
|
|
104
|
+
const result = await adapter.report(ctx);
|
|
105
|
+
results.push({
|
|
106
|
+
project: module.name,
|
|
107
|
+
target,
|
|
108
|
+
flavour: flavour2,
|
|
109
|
+
ok: result.ok,
|
|
110
|
+
data: result.metrics ?? {}
|
|
111
|
+
});
|
|
112
|
+
worstCode = Math.max(worstCode, result.code);
|
|
113
|
+
} else {
|
|
114
|
+
const config = await adapter.inspect(ctx);
|
|
115
|
+
results.push({ project: module.name, target, flavour: flavour2, ok: true, data: config });
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
119
|
+
results.push({ project: module.name, target, flavour: flavour2, ok: false, data: { error: message } });
|
|
120
|
+
worstCode = Math.max(worstCode, 1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
params.onProgress?.(done += 1, params.modules.length, module.name);
|
|
124
|
+
}
|
|
125
|
+
return { results, worstCode };
|
|
126
|
+
}
|
|
127
|
+
function generateSummary(result) {
|
|
128
|
+
const { project, target, flavour: flavour2, ok, data } = result;
|
|
129
|
+
const details = data && typeof data === "object" ? data : { value: data };
|
|
130
|
+
return { project, target, flavour: flavour2, ok, ...details };
|
|
131
|
+
}
|
|
132
|
+
function generateSummaries(results) {
|
|
133
|
+
return { schemaVersion: 1, results: results.map(generateSummary) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/core/settings.ts
|
|
137
|
+
var WORKSPACE_ROOT_MARKER = "nx.json";
|
|
138
|
+
|
|
139
|
+
// bin/sentinel.ts
|
|
140
|
+
program.name("sentinel").description("One CLI that guards code health: presets, analysis, and arch checks.").version(readOwnVersion()).configureHelp({ sortOptions: false }).showSuggestionAfterError(true).showHelpAfterError('(run "sentinel --help" for usage)').addHelpText(
|
|
141
|
+
"before",
|
|
142
|
+
[
|
|
143
|
+
"A check reads as: verb + target [+ --runner]. Run it from the app directory.",
|
|
144
|
+
" verb what to do: --run --inspect --update --report",
|
|
145
|
+
" target the check: --lint --typescript ... (or --all)",
|
|
146
|
+
" runner the tool behind a target (a default is set per target; override here)",
|
|
147
|
+
""
|
|
148
|
+
].join("\n")
|
|
149
|
+
).option("--run", "execute the target tool").option("--inspect", "show the resolved configuration").option("--update", "generate/apply the config stubs").option("--report", "metrics and health report").option("--lint", "linting").option("--format", "formatting").option("--typescript", "type checking").option("--build", "build").option("--test", "tests").option("--static-analysis", "cycles, complexity, duplication, centrality").option("--runtime-analysis", "bundle, Lighthouse, web vitals").option("--arch", "architecture boundaries").option("--all", "every target").option(
|
|
150
|
+
"--module <name>",
|
|
151
|
+
"scope to a module (planned; for now run sentinel from the module directory)"
|
|
152
|
+
).option("--flavour <name>", `stack preset, declared not detected (${FLAVOURS.join(", ")})`).option("--runner <tool>", "override the default runner (e.g. eslint, biome)").option("--ci", "CI mode: non-zero exit on failure (report/inspect: affected only)").option("--fix", "auto-fix where applicable").option("--dry-run", "preview the changes without writing (--update)").option("--json", "machine-readable JSON output (report/inspect/--dry-run)").addHelpText(
|
|
153
|
+
"after",
|
|
154
|
+
[
|
|
155
|
+
"",
|
|
156
|
+
"Examples (run from the app directory):",
|
|
157
|
+
" sentinel --run --typescript",
|
|
158
|
+
" sentinel --update --lint --flavour react",
|
|
159
|
+
" sentinel --run --lint --runner=oxlint",
|
|
160
|
+
" sentinel --report --all --ci"
|
|
161
|
+
].join("\n")
|
|
162
|
+
).parse();
|
|
163
|
+
var opts = program.opts();
|
|
164
|
+
function toCamel(flag) {
|
|
165
|
+
return flag.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
166
|
+
}
|
|
167
|
+
function pickOne(kind, keys) {
|
|
168
|
+
const chosen = keys.filter((k) => opts[toCamel(k)]);
|
|
169
|
+
if (chosen.length !== 1) {
|
|
170
|
+
const supported = keys.map((k) => `--${k}`).join(", ");
|
|
171
|
+
program.error(
|
|
172
|
+
chosen.length === 0 ? `Missing a ${kind}. Supported: ${supported}.` : `Pick exactly one ${kind}: got ${chosen.map((k) => `--${k}`).join(", ")}.`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return chosen[0];
|
|
176
|
+
}
|
|
177
|
+
function parseFlavour(value) {
|
|
178
|
+
if (value === void 0) return void 0;
|
|
179
|
+
if (typeof value !== "string" || !FLAVOURS.includes(value)) {
|
|
180
|
+
return program.error(
|
|
181
|
+
`sentinel: unknown --flavour ${JSON.stringify(value)}. Supported: ${FLAVOURS.join(", ")}.`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
function asMessage(err) {
|
|
187
|
+
return err instanceof Error ? err.message : String(err);
|
|
188
|
+
}
|
|
189
|
+
var verb = pickOne("verb", VERBS);
|
|
190
|
+
var isAnalyse = verb === "report" || verb === "inspect";
|
|
191
|
+
var cwd = process.cwd();
|
|
192
|
+
var namedTargets = TARGETS.filter((t) => opts[toCamel(t)]);
|
|
193
|
+
if (opts.all && namedTargets.length > 0) {
|
|
194
|
+
program.error(
|
|
195
|
+
`--all runs every target; drop the specific one(s): ${namedTargets.map((t) => `--${t}`).join(", ")}.`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
var targets = opts.all || isAnalyse && namedTargets.length === 0 ? [...TARGETS] : isAnalyse ? namedTargets : [pickOne("target", TARGETS)];
|
|
199
|
+
var flavour = parseFlavour(opts.flavour);
|
|
200
|
+
if (opts.dryRun && verb !== "update") {
|
|
201
|
+
program.error("--dry-run only applies to --update (report/inspect never write).");
|
|
202
|
+
}
|
|
203
|
+
if (opts.module && !isAnalyse) {
|
|
204
|
+
program.error(
|
|
205
|
+
"--module is not supported yet for --run/--update: cd into the module directory and run sentinel there."
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (!isAnalyse && existsSync(join2(cwd, WORKSPACE_ROOT_MARKER))) {
|
|
209
|
+
program.error(
|
|
210
|
+
`Run sentinel from a module directory, not the workspace root (found ${WORKSPACE_ROOT_MARKER} here). Per-module is the model until --module resolution lands.`
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
var MODULE_MARKERS = ["package.json", "project.json"];
|
|
214
|
+
if (!isAnalyse && !MODULE_MARKERS.some((marker) => existsSync(join2(cwd, marker)))) {
|
|
215
|
+
program.error(
|
|
216
|
+
`This directory is not a module (no ${MODULE_MARKERS.join(" or ")}). cd into the module you want to ${verb === "update" ? "update" : "check"} and run sentinel there.`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
async function runAnalyse() {
|
|
220
|
+
let modules;
|
|
221
|
+
if (opts.module) {
|
|
222
|
+
const found = discoverModules(cwd).find((m) => m.name === opts.module);
|
|
223
|
+
if (!found) program.error(`module "${opts.module}" not found in the workspace.`);
|
|
224
|
+
modules = [found];
|
|
225
|
+
} else {
|
|
226
|
+
modules = discoverModules(cwd, { affected: Boolean(opts.ci) });
|
|
227
|
+
}
|
|
228
|
+
const started = Date.now();
|
|
229
|
+
const { results, worstCode } = await analyse({
|
|
230
|
+
verb,
|
|
231
|
+
targets,
|
|
232
|
+
modules,
|
|
233
|
+
runner: opts.runner,
|
|
234
|
+
ci: Boolean(opts.ci),
|
|
235
|
+
fix: Boolean(opts.fix),
|
|
236
|
+
onProgress: (done, total, name) => process.stderr.write(` [${done}/${total}] ${name}
|
|
237
|
+
`)
|
|
238
|
+
});
|
|
239
|
+
const summary = generateSummaries(results);
|
|
240
|
+
if (opts.json) {
|
|
241
|
+
process.stdout.write(JSON.stringify(summary, null, 2) + "\n");
|
|
242
|
+
} else {
|
|
243
|
+
for (const item of summary.results) {
|
|
244
|
+
const details = Object.entries(item).filter(([key]) => !["project", "target", "flavour", "ok"].includes(key)).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(" ");
|
|
245
|
+
process.stdout.write(
|
|
246
|
+
` ${item.ok ? "\u2713" : "\u2717"} ${item.project} (${item.flavour}) ${item.target}${details ? ` \u2014 ${details}` : ""}
|
|
247
|
+
`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
process.stderr.write(` ${modules.length} module(s) in ${Date.now() - started}ms
|
|
252
|
+
`);
|
|
253
|
+
return opts.ci ? worstCode : 0;
|
|
254
|
+
}
|
|
255
|
+
async function runPerModule() {
|
|
256
|
+
let worst = 0;
|
|
257
|
+
for (const target of targets) {
|
|
258
|
+
try {
|
|
259
|
+
const code = await dispatch({
|
|
260
|
+
verb,
|
|
261
|
+
target,
|
|
262
|
+
runner: opts.runner,
|
|
263
|
+
module: basename(cwd),
|
|
264
|
+
cwd,
|
|
265
|
+
flavour,
|
|
266
|
+
ci: Boolean(opts.ci),
|
|
267
|
+
fix: Boolean(opts.fix),
|
|
268
|
+
dryRun: Boolean(opts.dryRun),
|
|
269
|
+
json: Boolean(opts.json)
|
|
270
|
+
});
|
|
271
|
+
worst = Math.max(worst, code);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
process.stderr.write(`
|
|
274
|
+
sentinel (${target}): ${asMessage(err)}
|
|
275
|
+
`);
|
|
276
|
+
if (targets.length === 1) return 1;
|
|
277
|
+
worst = Math.max(worst, 1);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return worst;
|
|
281
|
+
}
|
|
282
|
+
registerAdapters();
|
|
283
|
+
(isAnalyse ? runAnalyse() : runPerModule()).then((code) => process.exit(code)).catch((err) => {
|
|
284
|
+
process.stderr.write(`
|
|
285
|
+
sentinel: ${asMessage(err)}
|
|
286
|
+
`);
|
|
287
|
+
process.exit(1);
|
|
288
|
+
});
|
|
289
|
+
//# sourceMappingURL=sentinel.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../bin/sentinel.ts","../../src/core/discover-modules.ts","../../src/core/domain.ts","../../src/core/orchestrate.ts","../../src/core/settings.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * sentinel CLI: `sentinel <verb> <target> [options]`.\n *\n * Verbs and targets are boolean flags (exactly one verb; one target, or `--all`).\n * The CLI is generic: it resolves each target's adapter (honouring `--runner`) and\n * dispatches. It knows nothing about any specific tool; those arrive as adapters in\n * later tickets. It never detects the stack: the flavour is declared in a project's\n * committed config, or passed explicitly with `--flavour`.\n */\nimport { existsSync } from 'node:fs'\nimport { basename, join } from 'node:path'\n\nimport { program } from 'commander'\n\nimport { registerAdapters } from '../src/adapters.js'\nimport { discoverModules, type ModuleRef } from '../src/core/discover-modules.js'\nimport { FLAVOURS, TARGETS, VERBS, type Flavour, type Target } from '../src/core/domain.js'\nimport { dispatch } from '../src/core/dispatch.js'\nimport { analyse, generateSummaries } from '../src/core/orchestrate.js'\nimport { WORKSPACE_ROOT_MARKER } from '../src/core/settings.js'\nimport { readOwnVersion } from '../src/shared/package-json.js'\n\nprogram\n .name('sentinel')\n .description('One CLI that guards code health: presets, analysis, and arch checks.')\n .version(readOwnVersion())\n .configureHelp({ sortOptions: false })\n // On any parse error (unknown flag, missing value): suggest the closest flag and\n // point at --help, so a typo like `--lnt` gets \"did you mean --lint?\".\n .showSuggestionAfterError(true)\n .showHelpAfterError('(run \"sentinel --help\" for usage)')\n .addHelpText(\n 'before',\n [\n 'A check reads as: verb + target [+ --runner]. Run it from the app directory.',\n ' verb what to do: --run --inspect --update --report',\n ' target the check: --lint --typescript ... (or --all)',\n ' runner the tool behind a target (a default is set per target; override here)',\n '',\n ].join('\\n'),\n )\n // verbs (pick one)\n .option('--run', 'execute the target tool')\n .option('--inspect', 'show the resolved configuration')\n .option('--update', 'generate/apply the config stubs')\n .option('--report', 'metrics and health report')\n // targets (pick one, or --all)\n .option('--lint', 'linting')\n .option('--format', 'formatting')\n .option('--typescript', 'type checking')\n .option('--build', 'build')\n .option('--test', 'tests')\n .option('--static-analysis', 'cycles, complexity, duplication, centrality')\n .option('--runtime-analysis', 'bundle, Lighthouse, web vitals')\n .option('--arch', 'architecture boundaries')\n .option('--all', 'every target')\n // modifiers\n .option(\n '--module <name>',\n 'scope to a module (planned; for now run sentinel from the module directory)',\n )\n .option('--flavour <name>', `stack preset, declared not detected (${FLAVOURS.join(', ')})`)\n .option('--runner <tool>', 'override the default runner (e.g. eslint, biome)')\n .option('--ci', 'CI mode: non-zero exit on failure (report/inspect: affected only)')\n .option('--fix', 'auto-fix where applicable')\n .option('--dry-run', 'preview the changes without writing (--update)')\n .option('--json', 'machine-readable JSON output (report/inspect/--dry-run)')\n .addHelpText(\n 'after',\n [\n '',\n 'Examples (run from the app directory):',\n ' sentinel --run --typescript',\n ' sentinel --update --lint --flavour react',\n ' sentinel --run --lint --runner=oxlint',\n ' sentinel --report --all --ci',\n ].join('\\n'),\n )\n .parse()\n\nconst opts = program.opts()\n\n// commander camelCases hyphenated flags (--static-analysis -> staticAnalysis).\nfunction toCamel(flag: string): string {\n return flag.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())\n}\n\n/** Exactly one of `keys` must be flagged; return it, else fail with guidance. */\nfunction pickOne<T extends string>(kind: string, keys: readonly T[]): T {\n const chosen = keys.filter((k) => opts[toCamel(k)])\n if (chosen.length !== 1) {\n const supported = keys.map((k) => `--${k}`).join(', ')\n program.error(\n chosen.length === 0\n ? `Missing a ${kind}. Supported: ${supported}.`\n : `Pick exactly one ${kind}: got ${chosen.map((k) => `--${k}`).join(', ')}.`,\n )\n }\n return chosen[0] as T\n}\n\n/**\n * The explicit `--flavour`, VALIDATED, or undefined when not given. sentinel never\n * detects the stack: a project's flavour is declared in its committed config, or\n * passed here for `--update`. An unknown value is a hard error (typo caught), not\n * a silent guess.\n */\nfunction parseFlavour(value: unknown): Flavour | undefined {\n if (value === undefined) return undefined\n if (typeof value !== 'string' || !FLAVOURS.includes(value as Flavour)) {\n return program.error(\n `sentinel: unknown --flavour ${JSON.stringify(value)}. Supported: ${FLAVOURS.join(', ')}.`,\n )\n }\n return value as Flavour\n}\n\nfunction asMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err)\n}\n\nconst verb = pickOne('verb', VERBS)\n// report/inspect are read-only workspace queries; run/update mutate one module.\nconst isAnalyse = verb === 'report' || verb === 'inspect'\nconst cwd = process.cwd()\n\n// Reject a contradictory `--all --<target>` rather than silently ignoring one.\nconst namedTargets = TARGETS.filter((t) => opts[toCamel(t)])\nif (opts.all && namedTargets.length > 0) {\n program.error(\n `--all runs every target; drop the specific one(s): ${namedTargets.map((t) => `--${t}`).join(', ')}.`,\n )\n}\n\n// Targets: report/inspect default to ALL when none is named; run/update need one.\nconst targets: Target[] =\n opts.all || (isAnalyse && namedTargets.length === 0)\n ? [...TARGETS]\n : isAnalyse\n ? namedTargets\n : [pickOne('target', TARGETS)]\n\nconst flavour = parseFlavour(opts.flavour)\n\n// --dry-run only previews a mutation; report/inspect never write, so it is moot there.\nif (opts.dryRun && verb !== 'update') {\n program.error('--dry-run only applies to --update (report/inspect never write).')\n}\n\n// --module is resolved via nx for report/inspect; deferred for the cwd-based verbs.\nif (opts.module && !isAnalyse) {\n program.error(\n '--module is not supported yet for --run/--update: cd into the module directory and run sentinel there.',\n )\n}\n\n// Workspace-root guard: only the mutating/per-cwd verbs. report/inspect are meant\n// to run at the root (that is where they discover every module).\nif (!isAnalyse && existsSync(join(cwd, WORKSPACE_ROOT_MARKER))) {\n program.error(\n `Run sentinel from a module directory, not the workspace root (found ${WORKSPACE_ROOT_MARKER} here). Per-module is the model until --module resolution lands.`,\n )\n}\n\n// Positive module guard: run/update act on the current directory, so refuse to run\n// (and never scaffold files) unless it actually looks like a module. This stops a\n// mistyped path from creating a stray package.json/tsconfig anywhere on disk.\nconst MODULE_MARKERS = ['package.json', 'project.json']\nif (!isAnalyse && !MODULE_MARKERS.some((marker) => existsSync(join(cwd, marker)))) {\n program.error(\n `This directory is not a module (no ${MODULE_MARKERS.join(' or ')}). cd into the module you want to ${verb === 'update' ? 'update' : 'check'} and run sentinel there.`,\n )\n}\n\n/** report/inspect: discover the modules, analyse them, aggregate + print. */\nasync function runAnalyse(): Promise<number> {\n let modules: ModuleRef[]\n if (opts.module) {\n const found = discoverModules(cwd).find((m) => m.name === opts.module)\n if (!found) program.error(`module \"${opts.module}\" not found in the workspace.`)\n modules = [found]\n } else {\n modules = discoverModules(cwd, { affected: Boolean(opts.ci) })\n }\n\n const started = Date.now()\n const { results, worstCode } = await analyse({\n verb: verb as 'report' | 'inspect',\n targets,\n modules,\n runner: opts.runner,\n ci: Boolean(opts.ci),\n fix: Boolean(opts.fix),\n onProgress: (done, total, name) => process.stderr.write(` [${done}/${total}] ${name}\\n`),\n })\n\n const summary = generateSummaries(results)\n if (opts.json) {\n process.stdout.write(JSON.stringify(summary, null, 2) + '\\n')\n } else {\n for (const item of summary.results) {\n const details = Object.entries(item)\n .filter(([key]) => !['project', 'target', 'flavour', 'ok'].includes(key))\n .map(([key, value]) => `${key}=${JSON.stringify(value)}`)\n .join(' ')\n process.stdout.write(\n ` ${item.ok ? '✓' : '✗'} ${item.project} (${item.flavour}) ${item.target}${details ? ` — ${details}` : ''}\\n`,\n )\n }\n }\n process.stderr.write(` ${modules.length} module(s) in ${Date.now() - started}ms\\n`)\n return opts.ci ? worstCode : 0\n}\n\n/** run/update: operate on the current module (one target, or `--all`). */\nasync function runPerModule(): Promise<number> {\n let worst = 0\n for (const target of targets) {\n try {\n const code = await dispatch({\n verb,\n target,\n runner: opts.runner,\n module: basename(cwd),\n cwd,\n flavour,\n ci: Boolean(opts.ci),\n fix: Boolean(opts.fix),\n dryRun: Boolean(opts.dryRun),\n json: Boolean(opts.json),\n })\n worst = Math.max(worst, code)\n } catch (err) {\n // With --all, report per target and keep going; a single target fails hard.\n process.stderr.write(`\\nsentinel (${target}): ${asMessage(err)}\\n`)\n if (targets.length === 1) return 1\n worst = Math.max(worst, 1)\n }\n }\n return worst\n}\n\nregisterAdapters() // wire in every tool adapter; the CLI itself never lists them\n\n;(isAnalyse ? runAnalyse() : runPerModule())\n .then((code) => process.exit(code))\n .catch((err: unknown) => {\n process.stderr.write(`\\nsentinel: ${asMessage(err)}\\n`)\n process.exit(1)\n })\n","/**\n * Module discovery via nx. `--report`/`--inspect` without a `--module` analyse\n * every project; `--ci` narrows to the affected ones. We ask nx (its cached graph)\n * for the authoritative names + roots in a couple of calls, not one per project,\n * so it stays fast on the real monorepo.\n */\nimport { execFileSync } from 'node:child_process'\nimport { mkdtempSync, readFileSync, rmSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { join } from 'node:path'\n\nimport { resolveBin } from '../shared/resolve-bin.js'\n\n/** A discovered module: its nx project name and absolute root. */\nexport interface ModuleRef {\n name: string\n root: string\n}\n\nfunction runNx(cwd: string, args: string[]): string {\n const nx = resolveBin(cwd, 'nx') ?? 'nx'\n try {\n // Disable the daemon for deterministic, CI-friendly one-shot invocations.\n return execFileSync(nx, args, {\n cwd,\n encoding: 'utf8',\n env: { ...process.env, NX_DAEMON: 'false' },\n })\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n throw new Error(\n `sentinel: could not run nx (${message}). Is nx installed in this workspace, and are you at its root?`,\n { cause: error },\n )\n }\n}\n\n/** Every module (name -> absolute root) from the nx project graph, in one call. */\nfunction readGraph(cwd: string): ModuleRef[] {\n const dir = mkdtempSync(join(tmpdir(), 'sentinel-nx-'))\n const file = join(dir, 'graph.json')\n try {\n runNx(cwd, ['graph', '--file', file])\n const parsed = JSON.parse(readFileSync(file, 'utf8')) as {\n graph?: { nodes?: Record<string, { data: { root: string } }> }\n }\n const nodes = parsed.graph?.nodes\n if (!nodes || typeof nodes !== 'object') {\n throw new Error(\n 'sentinel: unexpected nx graph output (no graph.nodes); the installed nx version may be incompatible.',\n )\n }\n return Object.entries(nodes).map(([name, node]) => ({\n name,\n root: join(cwd, node.data.root),\n }))\n } finally {\n rmSync(dir, { recursive: true, force: true })\n }\n}\n\nexport function discoverModules(cwd: string, options: { affected?: boolean } = {}): ModuleRef[] {\n const modules = readGraph(cwd)\n if (!options.affected) return modules\n const affected = new Set(\n JSON.parse(runNx(cwd, ['show', 'projects', '--affected', '--json'])) as string[],\n )\n return modules.filter((module) => affected.has(module.name))\n}\n","/**\n * The domain vocabulary: the fixed sets of verbs, targets, and flavours, and the\n * types derived from them. This is the model, and the extension point: adding a\n * verb / target / flavour is a one-line edit to a list here, and because the types\n * are DERIVED (`(typeof LIST)[number]`), the compiler forces every switch/handler\n * to cover the new member.\n *\n * Tunable behaviour (defaults, detection signals, marker filenames) lives in\n * `settings.ts`, not here.\n */\n\n/** Verbs: what to do. Each maps to an adapter method in dispatch. */\nexport const VERBS = ['run', 'inspect', 'update', 'report'] as const\nexport type Verb = (typeof VERBS)[number]\n\n/** Targets: the kind of check. The CLI `--<target>` flags map 1:1 to these. */\nexport const TARGETS = [\n 'lint',\n 'format',\n 'typescript',\n 'build',\n 'test',\n 'static-analysis',\n 'runtime-analysis',\n 'arch',\n] as const\nexport type Target = (typeof TARGETS)[number]\n\n/** Flavours: the stack preset a project resolves to (strict by default). */\nexport const FLAVOURS = ['react', 'nest', 'svelte', 'node'] as const\nexport type Flavour = (typeof FLAVOURS)[number]\n","/**\n * Orchestration for `--report`/`--inspect`: analyse a set of modules across a set\n * of targets, then shape the outcomes into one versioned, parseable summary.\n *\n * The engine resolves each module's flavour and adapter and calls the per-module\n * method; a target with no adapter yet is simply skipped. `generateSummaries`\n * builds the aggregate by reusing `generateSummary` for each result (one factory,\n * no duplicated shaping between the single- and multi-module paths).\n */\nimport { readProjectPackageJson } from '../shared/package-json.js'\nimport { detectFramework } from './detect-framework.js'\nimport type { ModuleRef } from './discover-modules.js'\nimport type { Flavour, Target } from './domain.js'\nimport { resolve } from './registry.js'\nimport type { RunContext } from './types.js'\n\n/** One project × target outcome. */\nexport interface AnalyseResult {\n project: string\n target: Target\n flavour: Flavour\n ok: boolean\n /** `--report`: metrics (errors, implicit-any); `--inspect`: the resolved config. */\n data: unknown\n}\n\nexport interface AnalyseParams {\n verb: 'report' | 'inspect'\n targets: readonly Target[]\n modules: readonly ModuleRef[]\n runner?: string\n ci: boolean\n fix: boolean\n /** Called after each module, for progress display. */\n onProgress?: (done: number, total: number, moduleName: string) => void\n}\n\n/** Run the analyse across modules × targets. Returns the results + worst exit code. */\nexport async function analyse(\n params: AnalyseParams,\n): Promise<{ results: AnalyseResult[]; worstCode: number }> {\n const results: AnalyseResult[] = []\n let worstCode = 0\n let done = 0\n\n for (const module of params.modules) {\n const flavour = detectFramework(readProjectPackageJson(module.root))\n const ctx: RunContext = {\n module: module.name,\n cwd: module.root,\n flavour,\n ci: params.ci,\n fix: params.fix,\n }\n for (const target of params.targets) {\n let adapter\n try {\n adapter = resolve(target, flavour, params.runner)\n } catch {\n continue // no adapter for this target yet: skip it (not a failure)\n }\n // Isolate every check: one module/target that throws must not abort the whole\n // sweep. A failure becomes a `ok:false` row (with the error) so `--report --all`\n // still returns a complete picture across a large workspace.\n try {\n if (params.verb === 'report') {\n const result = await adapter.report(ctx)\n results.push({\n project: module.name,\n target,\n flavour,\n ok: result.ok,\n data: result.metrics ?? {},\n })\n worstCode = Math.max(worstCode, result.code)\n } else {\n const config = await adapter.inspect(ctx)\n results.push({ project: module.name, target, flavour, ok: true, data: config })\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n results.push({ project: module.name, target, flavour, ok: false, data: { error: message } })\n worstCode = Math.max(worstCode, 1)\n }\n }\n params.onProgress?.((done += 1), params.modules.length, module.name)\n }\n\n return { results, worstCode }\n}\n\n/** The shaped summary for ONE result (the factory unit). */\nexport function generateSummary(result: AnalyseResult): Record<string, unknown> {\n const { project, target, flavour, ok, data } = result\n const details =\n data && typeof data === 'object' ? (data as Record<string, unknown>) : { value: data }\n return { project, target, flavour, ok, ...details }\n}\n\n/** The aggregate, versioned envelope for MANY results, built from `generateSummary`. */\nexport function generateSummaries(results: readonly AnalyseResult[]): {\n schemaVersion: number\n results: Record<string, unknown>[]\n} {\n return { schemaVersion: 1, results: results.map(generateSummary) }\n}\n","/**\n * Tunable settings: the knobs you would actually change. The fixed vocabulary and\n * its types live in `domain.ts`.\n *\n * Note: sentinel does NOT detect the flavour. A project's flavour is declared in\n * its committed config (the stub's `extends`), or passed explicitly to `--update`.\n * We never guess, so there is no default flavour or detection table here.\n */\n\n/** A file that marks a workspace root; sentinel refuses to operate there. */\nexport const WORKSPACE_ROOT_MARKER = 'nx.json'\n"],"mappings":"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","names":["join","cwd","flavour","join"]}
|