@hublo/sentinel 1.5.0-alpha.9 → 1.5.1-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.
Files changed (43) hide show
  1. package/README.md +12 -8
  2. package/dist/bin/sentinel.js +246 -24
  3. package/dist/chunk-CL7HOHTU.js +10442 -0
  4. package/dist/{chunk-KLEXJFJ6.js → chunk-I2WDZNPR.js} +115 -33
  5. package/dist/{chunk-YQRDKWSH.js → chunk-JRZI5IIN.js} +11 -1
  6. package/dist/chunk-JRZI5IIN.js.map +1 -0
  7. package/dist/{chunk-AMCGMLPS.js → chunk-LFNOBQZU.js} +2 -2
  8. package/dist/chunk-WBDBRGLV.js +214 -0
  9. package/dist/chunk-WBDBRGLV.js.map +1 -0
  10. package/dist/index.d.ts +76 -1
  11. package/dist/index.js +4 -4
  12. package/dist/roles/build/nest/toolchain.js +68 -18
  13. package/dist/roles/build/nest/toolchain.js.map +1 -1
  14. package/dist/roles/static-analysis/config.d.ts +15 -1
  15. package/dist/roles/static-analysis/config.js +1 -1
  16. package/dist/roles/test/setup/jest-parity.js +2 -2
  17. package/dist/roles/test/setup/workspace-entry.js +2 -2
  18. package/dist/{validate-AWWMBSH5.js → validate-S6PIOVAH.js} +2 -2
  19. package/docs/bundle-analysis-spec.md +272 -0
  20. package/docs/format-adoption.md +13 -9
  21. package/docs/lint-adoption.md +21 -9
  22. package/docs/sql-verification-study.md +401 -0
  23. package/docs/static-analysis-adoption.md +285 -0
  24. package/docs/static-analysis-study.md +824 -18
  25. package/docs/typescript-adoption.md +2 -1
  26. package/docs/typescript-traces.md +275 -11
  27. package/docs/using-sentinel.md +31 -13
  28. package/docs/validating-a-change.md +185 -0
  29. package/lint/nest.json +98 -18
  30. package/lint/node.json +83 -3
  31. package/lint/react-lib.json +121 -31
  32. package/lint/react.json +101 -2
  33. package/package.json +27 -6
  34. package/plugins/hublo/no-palette-common.js +76 -0
  35. package/plugins/hublo/no-regex-lookbehind.js +70 -0
  36. package/plugins/hublo/require-reload-document.js +70 -0
  37. package/plugins/hublo.js +20 -145
  38. package/plugins/sonar-cognitive.js +108 -0
  39. package/dist/chunk-EDFIVLUA.js +0 -100
  40. package/dist/chunk-EDFIVLUA.js.map +0 -1
  41. package/dist/chunk-OZZICSSZ.js +0 -6469
  42. package/dist/chunk-YQRDKWSH.js.map +0 -1
  43. /package/dist/{chunk-AMCGMLPS.js.map → chunk-LFNOBQZU.js.map} +0 -0
package/README.md CHANGED
@@ -57,7 +57,8 @@ pnpm install # fetch what --init declared, then commit
57
57
 
58
58
  With no target named, `--init` adopts every role, in the order that makes the result correct:
59
59
  the linter's autofix runs, then the formatter runs **last** and formats everything every role
60
- wrote. Naming one target (`--init --lint`) adopts just that role and leaves the rest alone.
60
+ wrote. Naming a target (`--init --build`) adopts just that role and leaves the rest alone, with
61
+ one pairing: lint and format are always adopted together, so `--init --lint` adopts oxfmt too.
61
62
 
62
63
  `--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.
63
64
 
@@ -137,7 +138,7 @@ The per-tool knowledge (eslint → `eslint.config.js`, tsc → `tsconfig`, …)
137
138
 
138
139
  **Registry: public npm**, under `@hublo`. We started on **GitHub Packages (private)** and moved off it: it authenticates every consumer, including a one-off run, which is incompatible with the zero-setup adoption above. Public npm needs no consumer auth, so a module adopts sentinel without any `.npmrc` or token. Releases go out through the repo's `publish` workflow, never from a laptop; a prerelease is published under its prerelease dist-tag (`alpha`) and a stable one under `latest`.
139
140
 
140
- **Node.** sentinel needs **Node >= 20.12** (its coloured output uses `util.styleText`, added in 20.12). It fails fast with a clear message on an older runtime rather than crashing. If a project runs on an older Node (e.g. a legacy app on Node 10), run sentinel with a modern Node via `fnm`/`nvm`; you do not need to change the project's own Node.
141
+ **Node.** sentinel needs **Node >= 24.15**, which is the version the monorepo it serves pins in its own `.node-version`. The floor follows the consumer deliberately: a lower one means testing a runtime nobody runs, and it twice let an API through that the types allowed and the runtime did not. It fails fast with a clear message on an older runtime rather than crashing. If a project runs on an older Node (e.g. a legacy app on Node 10), run sentinel with a modern Node via `fnm`/`nvm`; you do not need to change the project's own Node.
141
142
 
142
143
  **Try it without installing.** A one-off run needs no auth and touches nothing:
143
144
 
@@ -171,19 +172,21 @@ throwaway run wants. Once a module has adopted, nothing is fetched any more: the
171
172
  - [`docs/build-adoption.md`](docs/build-adoption.md) — moving a React app's Vite toolchain into sentinel: adoption changes the config's IMPORTS and nothing else, what sentinel owns versus what stays the app's, and why reading nothing is what makes losing nothing a guarantee.
172
173
  - [`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.
173
174
  - [`docs/test-adoption.md`](docs/test-adoption.md) — migrating a module from jest to Vitest: the two steps, what the migration rewrites and what it refuses BY NAME, how the baseline gate proves nothing was lost, and the failures worth recognising.
175
+ - [`docs/static-analysis-adoption.md`](docs/static-analysis-adoption.md) — running the static analysis role on a module: the two steps, the four output shapes, how to read each row and where its threshold comes from, declaring an entry point, and the two limits worth knowing before trusting a number.
174
176
  - [`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`.
175
177
 
176
178
  ## Architecture: `target → runner → preset`
177
179
 
178
180
  Every check is described by three layers:
179
181
 
180
- | Layer | Flag | What it is | Examples |
181
- | -------------------- | --------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
182
- | **target** (role) | `--lint`, `--typescript`, … | the _kind_ of check, stable | `lint` `format` `typescript` `build` `dev` `test` `static-analysis` `runtime-analysis` |
183
- | **runner** (adapter) | `--runner=<tool>` | the _tool_ behind the target, swappable | lint: `eslint`/`biome`/`oxlint` · types: `tsc`/`tsgo` · build: `vite` · test: `vitest` |
184
- | **preset** (preset) | detected / `--preset` | the _variant_ per stack (strict by default) | `react` `nest` `node` `svelte` (svelte: lint + format; no tsconfig preset yet, so the TypeScript role stands aside on those modules and the other two adopt) |
182
+ | Layer | Flag | What it is | Examples |
183
+ | -------------------- | --------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
184
+ | **target** (role) | `--lint`, `--typescript`, … | the _kind_ of check, stable | `lint` `format` `typescript` `build` `dev` `test` `static-analysis` `runtime-analysis` |
185
+ | **runner** (adapter) | `--runner=<tool>` | the _tool_ behind the target, swappable | lint: `eslint`/`biome`/`oxlint` · types: `tsc`/`tsgo` · build: `vite` · test: `vitest` |
186
+ | **shape** (output) | `--output=<shape>` | what a reporting role WRITES, not what it measures | `console` (default, writes nothing) `html` `json` `markdown`; roles that do not report ignore it |
187
+ | **preset** (preset) | detected / `--preset` | the _variant_ per stack (strict by default) | `react` `nest` `node` `svelte` (svelte: lint + format; no tsconfig preset yet, so the TypeScript role stands aside on those modules and the other two adopt) |
185
188
 
186
- A run is `target × runner × preset`, e.g. `sentinel --run --lint --runner=eslint` from the `host-admin` dir.
189
+ A run is `target × runner × preset`, e.g. `sentinel --run --lint --runner=eslint` from the `host-admin` dir. The shape is not part of that identity: it decides what the run WRITES, not what it does.
187
190
 
188
191
  - `--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).
189
192
  - 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.
@@ -410,6 +413,7 @@ OPTIONS --module <name> from the root: scope to one module
410
413
  --ci from the root: affected only; non-zero exit on failure
411
414
  --fix auto-fix where applicable
412
415
  --dry-run preview a --init without writing
416
+ --rehearse with --dry-run: run the autofixes in a copy, name the source they rewrite
413
417
  --json machine-readable output (report / inspect / status / --dry-run)
414
418
  -- <tool options> --run only: pass the rest to the tool itself (one type)
415
419
 
@@ -1,11 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- LONG_RUNNING_TARGETS,
4
- PRESET_NAMES,
5
3
  PresetUnsupportedError,
6
- SWEEPABLE_TARGETS,
7
- TARGETS,
8
- VERBS,
9
4
  availableTargets,
10
5
  buildConfigFile,
11
6
  correctingSignal,
@@ -13,35 +8,157 @@ import {
13
8
  describeFramework,
14
9
  dispatch,
15
10
  palette,
11
+ presetNotInstalledYet,
16
12
  registerAdapters,
17
13
  resolve
18
- } from "../chunk-OZZICSSZ.js";
14
+ } from "../chunk-CL7HOHTU.js";
19
15
  import {
20
16
  resolveContext
21
- } from "../chunk-KLEXJFJ6.js";
17
+ } from "../chunk-I2WDZNPR.js";
22
18
  import {
23
19
  ensureWorkspacePrep,
24
20
  findWorkspaceRoot,
25
21
  inspectWorkspacePrep,
26
22
  readOwnVersion,
27
23
  readProjectPackageJson
28
- } from "../chunk-YQRDKWSH.js";
29
- import "../chunk-EDFIVLUA.js";
24
+ } from "../chunk-JRZI5IIN.js";
25
+ import {
26
+ LONG_RUNNING_TARGETS,
27
+ OUTPUT_SHAPES,
28
+ PRESET_NAMES,
29
+ SWEEPABLE_TARGETS,
30
+ TARGETS,
31
+ VERBS
32
+ } from "../chunk-WBDBRGLV.js";
30
33
  import "../chunk-O7REVMOC.js";
31
34
  import "../chunk-QXFCZON7.js";
32
35
 
33
36
  // bin/sentinel.ts
34
37
  import { program } from "commander";
35
38
 
39
+ // src/core/rehearse.ts
40
+ import { createHash } from "node:crypto";
41
+ import {
42
+ cpSync,
43
+ existsSync,
44
+ lstatSync,
45
+ mkdirSync,
46
+ mkdtempSync,
47
+ readdirSync,
48
+ readFileSync,
49
+ realpathSync,
50
+ rmSync,
51
+ symlinkSync,
52
+ writeFileSync
53
+ } from "node:fs";
54
+ import { tmpdir } from "node:os";
55
+ import { basename, dirname, join, relative, sep } from "node:path";
56
+ var NOT_COPIED = /* @__PURE__ */ new Set(["node_modules", ".git"]);
57
+ function contents(root) {
58
+ const found = /* @__PURE__ */ new Map();
59
+ const queue = [root];
60
+ while (queue.length > 0) {
61
+ const dir = queue.pop();
62
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
63
+ const path = join(dir, entry.name);
64
+ if (entry.isDirectory()) {
65
+ if (!NOT_COPIED.has(entry.name)) queue.push(path);
66
+ continue;
67
+ }
68
+ if (!entry.isFile()) continue;
69
+ found.set(relative(root, path), createHash("sha1").update(readFileSync(path)).digest("hex"));
70
+ }
71
+ }
72
+ return found;
73
+ }
74
+ function stage(moduleDir, scratch) {
75
+ const root = findWorkspaceRoot(moduleDir);
76
+ const segments = root === void 0 ? [basename(moduleDir)] : relative(root, moduleDir).split(sep).filter((segment) => segment !== "");
77
+ let real = root ?? dirname(moduleDir);
78
+ let shadow = scratch;
79
+ for (const segment of segments) {
80
+ for (const entry of root === void 0 ? [] : readdirSync(real)) {
81
+ if (entry === segment || entry === ".git") continue;
82
+ symlinkSync(join(real, entry), join(shadow, entry));
83
+ }
84
+ real = join(real, segment);
85
+ shadow = join(shadow, segment);
86
+ mkdirSync(shadow);
87
+ }
88
+ rmSync(shadow, { recursive: true, force: true });
89
+ cpSync(moduleDir, shadow, {
90
+ recursive: true,
91
+ filter: (source) => source === moduleDir || !NOT_COPIED.has(basename(source)) && !lstatSync(source).isSymbolicLink()
92
+ });
93
+ if (existsSync(join(moduleDir, "node_modules"))) {
94
+ symlinkSync(join(moduleDir, "node_modules"), join(shadow, "node_modules"), "dir");
95
+ }
96
+ return shadow;
97
+ }
98
+ async function rehearseAfterInit(moduleDir, roles) {
99
+ const rehearsing = roles.filter((role) => role.adapter.rehearseAfterInit !== void 0);
100
+ if (rehearsing.length === 0) return [];
101
+ const scratch = realpathSync(mkdtempSync(join(tmpdir(), "sentinel-rehearsal-")));
102
+ try {
103
+ let copy;
104
+ try {
105
+ copy = stage(moduleDir, scratch);
106
+ } catch (error) {
107
+ const failure = error instanceof Error ? error.message : String(error);
108
+ return rehearsing.map((role) => ({ target: role.target, rewrites: [], failure }));
109
+ }
110
+ const planned = /* @__PURE__ */ new Set();
111
+ for (const role of roles) {
112
+ for (const file of role.files) {
113
+ planned.add(file.path);
114
+ const target = join(copy, file.path);
115
+ if (file.deleted) {
116
+ rmSync(target, { force: true });
117
+ continue;
118
+ }
119
+ mkdirSync(dirname(target), { recursive: true });
120
+ writeFileSync(target, file.after);
121
+ }
122
+ }
123
+ const results = [];
124
+ for (const role of rehearsing) {
125
+ const before = contents(copy);
126
+ const failure = await role.adapter.rehearseAfterInit?.(
127
+ {
128
+ module: basename(moduleDir),
129
+ cwd: copy,
130
+ preset: role.preset,
131
+ ci: false,
132
+ fix: true,
133
+ written: role.files.filter((file) => !file.deleted).map((file) => file.path)
134
+ },
135
+ moduleDir
136
+ );
137
+ const after = contents(copy);
138
+ const rewrites = [...after].filter(([path, hash]) => before.get(path) !== hash && !planned.has(path)).map(([path]) => path).sort();
139
+ results.push({
140
+ target: role.target,
141
+ rewrites,
142
+ // The tool names files by the path it read them from, which is the copy. Said as the
143
+ // module's own paths: the copy is gone by the time anyone reads this.
144
+ ...failure === void 0 ? {} : { failure: failure.replaceAll(`${copy}/`, "").replaceAll(copy, ".") }
145
+ });
146
+ }
147
+ return results;
148
+ } finally {
149
+ rmSync(scratch, { recursive: true, force: true });
150
+ }
151
+ }
152
+
36
153
  // src/roles/build/declined.ts
37
- import { existsSync, readFileSync } from "node:fs";
38
- import { join } from "node:path";
154
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
155
+ import { join as join2 } from "node:path";
39
156
  var DEPRECATED_NX_BUILDER = "@nx/webpack:webpack";
40
157
  function buildsWithDeprecatedExecutor(cwd2) {
41
- const projectJson = join(cwd2, "project.json");
42
- if (!existsSync(projectJson)) return false;
158
+ const projectJson = join2(cwd2, "project.json");
159
+ if (!existsSync2(projectJson)) return false;
43
160
  try {
44
- const project = JSON.parse(readFileSync(projectJson, "utf8"));
161
+ const project = JSON.parse(readFileSync2(projectJson, "utf8"));
45
162
  return project.targets?.build?.executor === DEPRECATED_NX_BUILDER;
46
163
  } catch {
47
164
  return false;
@@ -61,6 +178,30 @@ function explainDecline(cwd2) {
61
178
  function asMessage(error) {
62
179
  return error instanceof Error ? error.message : String(error);
63
180
  }
181
+ var ADOPTED_TOGETHER = ["lint", "format"];
182
+ function pairedTargets(named, available) {
183
+ if (!named.some((type) => ADOPTED_TOGETHER.includes(type))) return [];
184
+ return ADOPTED_TOGETHER.filter((type) => available.includes(type) && !named.includes(type));
185
+ }
186
+ var NAMED_REWRITES = 10;
187
+ var AUTOFIX_TOOL = { lint: "linter", format: "formatter" };
188
+ function describeUnrehearsed(target) {
189
+ return `the ${target} autofix runs on adoption and can rewrite source, as moving to another ${AUTOFIX_TOOL[target] ?? "tool"} does; add --rehearse to name the files (it runs the pass in a throwaway copy, as long as the real one)`;
190
+ }
191
+ function describeRehearsal(target, rewrites, failure) {
192
+ const pass = `the ${target} autofix`;
193
+ const said = failure === void 0 ? "" : ` (it also reported: ${failure})`;
194
+ if (rewrites.length === 0) {
195
+ if (failure === void 0) return `${pass} would rewrite nothing beyond the files above`;
196
+ if (presetNotInstalledYet(failure)) {
197
+ return `${pass} cannot be rehearsed yet: its preset is not installed in this module, which is expected before the first \`pnpm install\`. After it, this dry-run names what it rewrites`;
198
+ }
199
+ return `could not rehearse ${pass}, so what it rewrites is not known: ${failure}`;
200
+ }
201
+ const named = rewrites.slice(0, NAMED_REWRITES).join(", ");
202
+ const rest = rewrites.length - NAMED_REWRITES;
203
+ return `${pass} would rewrite ${rewrites.length} file${rewrites.length === 1 ? "" : "s"} beyond the ones above: ${rest > 0 ? `${named} and ${rest} more` : named}${said}`;
204
+ }
64
205
  async function runInit(ctx) {
65
206
  const { modules, scope } = resolveContext(ctx.cwd, { module: ctx.module, ci: false });
66
207
  const [module] = modules;
@@ -78,7 +219,24 @@ async function runInit(ctx) {
78
219
  );
79
220
  }
80
221
  }
81
- const toRun = ctx.targets.filter((type) => available.includes(type)).sort((a, b) => Number(a === "format") - Number(b === "format"));
222
+ const named = ctx.targets.filter((type) => available.includes(type));
223
+ const paired = pairedTargets(named, available);
224
+ const toRun = [...named, ...paired].sort(
225
+ (a, b) => Number(a === "format") - Number(b === "format")
226
+ );
227
+ for (const type of paired) {
228
+ process.stderr.write(
229
+ palette(process.stderr).dim(
230
+ ` adopting --${type} too: lint and format are adopted together, see the lint adoption guide
231
+ `
232
+ )
233
+ );
234
+ }
235
+ const outcomes = [];
236
+ const record = (outcome) => {
237
+ outcomes.push(outcome);
238
+ };
239
+ const planned = [];
82
240
  let worst = 0;
83
241
  for (const type of toRun) {
84
242
  try {
@@ -89,12 +247,14 @@ async function runInit(ctx) {
89
247
  cwd: module.root,
90
248
  preset: ctx.preset,
91
249
  dryRun: ctx.dryRun,
92
- json: ctx.json
250
+ json: ctx.json,
251
+ record,
252
+ planned: (role) => planned.push(role)
93
253
  });
94
254
  worst = Math.max(worst, code);
95
255
  } catch (error) {
96
256
  if (error instanceof PresetUnsupportedError) {
97
- if (ctx.targetsExplicit) {
257
+ if (ctx.targetsExplicit && !paired.includes(type)) {
98
258
  const hint = type === "build" ? explainDecline(module.root) : ` Pass --preset if it was detected wrongly (hoisted dependencies make detection fall back to "node").`;
99
259
  process.stderr.write(
100
260
  `
@@ -103,14 +263,36 @@ sentinel (${type}): this module's preset is "${error.preset}", and --${type} has
103
263
  );
104
264
  worst = Math.max(worst, 1);
105
265
  }
266
+ record({
267
+ role: type,
268
+ status: "unsupported",
269
+ reason: `this module's preset is "${error.preset}", and --${type} has no adapter for it.`
270
+ });
106
271
  continue;
107
272
  }
108
273
  process.stderr.write(`
109
274
  sentinel (${type}): ${asMessage(error)}
110
275
  `);
276
+ record({ role: type, status: "failed", reason: asMessage(error) });
111
277
  worst = Math.max(worst, 1);
112
278
  }
113
279
  }
280
+ if (ctx.dryRun && worst === 0 && !ctx.rehearse && !ctx.json) {
281
+ for (const { target } of planned.filter((role) => role.adapter.rehearseAfterInit)) {
282
+ process.stderr.write(` dry run: ${describeUnrehearsed(target)}
283
+ `);
284
+ }
285
+ }
286
+ if (ctx.dryRun && worst === 0 && ctx.rehearse) {
287
+ for (const { target, rewrites, failure } of await rehearseAfterInit(module.root, planned)) {
288
+ const outcome = outcomes.find((entry) => entry.role === target);
289
+ if (outcome !== void 0) outcome.rewrites = rewrites;
290
+ if (ctx.json) continue;
291
+ process.stderr.write(` dry run: ${describeRehearsal(target, rewrites, failure)}
292
+ `);
293
+ }
294
+ }
295
+ let workspace = [];
114
296
  if (worst === 0) {
115
297
  const root = findWorkspaceRoot(module.root);
116
298
  if (root) {
@@ -123,6 +305,7 @@ sentinel (${type}): ${asMessage(error)}
123
305
  // Same rule for the report directory: only when the STATIC ANALYSIS role adopted.
124
306
  analysedModule: toRun.includes("static-analysis")
125
307
  });
308
+ workspace = changes;
126
309
  const prefix = ctx.dryRun ? " dry run: " : " ";
127
310
  for (const change of changes) process.stderr.write(`${prefix}${change}
128
311
  `);
@@ -133,6 +316,15 @@ sentinel (${type}): ${asMessage(error)}
133
316
  }
134
317
  }
135
318
  }
319
+ if (ctx.json) {
320
+ process.stdout.write(
321
+ JSON.stringify(
322
+ { dryRun: ctx.dryRun, module: module.name, roles: outcomes, workspace },
323
+ null,
324
+ 2
325
+ ) + "\n"
326
+ );
327
+ }
136
328
  return worst;
137
329
  }
138
330
 
@@ -148,7 +340,7 @@ async function runValidate(ctx) {
148
340
  );
149
341
  }
150
342
  if (!ctx.targets.includes("test")) return 1;
151
- const { validateModuleTests } = await import("../validate-AWWMBSH5.js");
343
+ const { validateModuleTests } = await import("../validate-S6PIOVAH.js");
152
344
  const outcomes = modules.flatMap(
153
345
  (module) => validateModuleTests(module.name, module.root)
154
346
  );
@@ -549,7 +741,7 @@ async function runVerb(ctx) {
549
741
  }
550
742
 
551
743
  // src/shared/node-version.ts
552
- var MIN_NODE = "20.12.0";
744
+ var MIN_NODE = "24.15.0";
553
745
  function parts(version) {
554
746
  const [major = 0, minor = 0, patch = 0] = version.replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
555
747
  return [major, minor, patch];
@@ -583,8 +775,8 @@ var TARGET_HELP = {
583
775
  build: "build",
584
776
  dev: "dev server (long-running: never swept by an unqualified --run)",
585
777
  test: "tests",
586
- "static-analysis": "cycles, complexity, duplication, centrality",
587
- "runtime-analysis": "bundle, Lighthouse, web vitals",
778
+ "static-analysis": "duplication, dead code, complexity, import graph, file size, bundle weight",
779
+ "runtime-analysis": "Lighthouse, web vitals",
588
780
  arch: "architecture boundaries"
589
781
  };
590
782
  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(
@@ -603,9 +795,12 @@ program.name("sentinel").description("One CLI that guards code health: presets,
603
795
  ).option("--report", "deprecated: use --run --json").option("--status", "deprecated: use --inspect").option(
604
796
  "--module <name>",
605
797
  "from the workspace root: scope to one module (omit = all; inside a module dir, drop this)"
606
- ).option("--preset <name>", `the stack preset to apply (${PRESET_NAMES.join(", ")})`).option("--flavour <name>", "deprecated: use --preset").option("--runner <tool>", "override the default runner (e.g. eslint, biome)").option("--ci", "CI mode: from the root, only the affected modules; non-zero exit on failure").option("--fix", "auto-fix where applicable").option("--dry-run", "preview the changes without writing (--init)").option("--json", "machine-readable output, for every verb").option(
798
+ ).option("--preset <name>", `the stack preset to apply (${PRESET_NAMES.join(", ")})`).option("--flavour <name>", "deprecated: use --preset").option("--runner <tool>", "override the default runner (e.g. eslint, biome)").option("--ci", "CI mode: from the root, only the affected modules; non-zero exit on failure").option("--fix", "auto-fix where applicable").option("--dry-run", "preview the changes without writing (--init)").option(
799
+ "--rehearse",
800
+ "with --init --dry-run: run the lint and format autofixes in a throwaway copy and name the source they rewrite (as slow as the real pass)"
801
+ ).option("--json", "machine-readable output, for every verb").option(
607
802
  "--output <shape>",
608
- "output shape for a role that writes reports (console, json, html); console by default"
803
+ "output shape for a role that writes reports (console, json, html, markdown); console by default"
609
804
  ).option(
610
805
  "--max-diagnostics <n>",
611
806
  "cap the diagnostics embedded per module in --report (0 = no cap)",
@@ -665,6 +860,22 @@ function parseFlavour(value) {
665
860
  }
666
861
  return value;
667
862
  }
863
+ function parseOutput(value, json) {
864
+ if (value === void 0) return void 0;
865
+ const asked = typeof value === "string" ? value.split(",").map((one) => one.trim()).filter((one) => one !== "") : [];
866
+ const unknown = asked.filter((one) => !OUTPUT_SHAPES.includes(one));
867
+ if (asked.length === 0 || unknown.length > 0) {
868
+ return program.error(
869
+ `sentinel: unknown --output ${unknown.map((one) => JSON.stringify(one)).join(", ") || JSON.stringify(value)}. Supported: ${OUTPUT_SHAPES.join(", ")}; several at once with a comma.`
870
+ );
871
+ }
872
+ if (json && asked.some((one) => one !== "json")) {
873
+ return program.error(
874
+ `sentinel: --json already selects the json shape, so it cannot be combined with --output ${JSON.stringify(value)}. Use one or the other.`
875
+ );
876
+ }
877
+ return asked.join(",");
878
+ }
668
879
  function asMessage2(err) {
669
880
  return err instanceof Error ? err.message : String(err);
670
881
  }
@@ -688,6 +899,11 @@ if (opts.all && namedTargets.length > 0) {
688
899
  }
689
900
  var targets = opts.all || namedTargets.length === 0 ? [...SWEEPABLE_TARGETS] : namedTargets;
690
901
  var targetsExplicit = !(opts.all || namedTargets.length === 0);
902
+ if (namedTargets.includes("static-analysis") && (verb === "report" || verb === "run" && opts.json)) {
903
+ program.error(
904
+ `sentinel: --static-analysis writes its own report, so ${verb === "report" ? "--report" : "--json"} has nothing to add to it. Run it bare for the console report, or with --output json|html|markdown for the files.`
905
+ );
906
+ }
691
907
  var maxDiagnostics = Number(opts.maxDiagnostics);
692
908
  if (!Number.isInteger(maxDiagnostics) || maxDiagnostics < 0) {
693
909
  program.error(
@@ -697,6 +913,11 @@ if (!Number.isInteger(maxDiagnostics) || maxDiagnostics < 0) {
697
913
  if (opts.dryRun && verb !== "init") {
698
914
  program.error("--dry-run only applies to --init (the read verbs never write).");
699
915
  }
916
+ if (opts.rehearse && !opts.dryRun) {
917
+ program.error(
918
+ "--rehearse only applies to --init --dry-run: the real --init runs the autofix passes themselves."
919
+ );
920
+ }
700
921
  if (toolArgs.length > 0) {
701
922
  if (verb !== "run") {
702
923
  program.error(
@@ -734,8 +955,9 @@ async function main() {
734
955
  json: Boolean(opts.json),
735
956
  // `--json` stays the engine-wide machine-readable flag, so it also selects the json shape for
736
957
  // a role that writes reports: two ways to ask for the same thing would be a trap.
737
- output: typeof opts.output === "string" ? opts.output : opts.json ? "json" : void 0,
958
+ output: parseOutput(opts.output, Boolean(opts.json)) ?? (opts.json ? "json" : void 0),
738
959
  dryRun: Boolean(opts.dryRun),
960
+ rehearse: Boolean(opts.rehearse),
739
961
  fail: (message) => program.error(message)
740
962
  };
741
963
  const exitCode = verb === "init" ? await runInit(context) : verb === "validate" ? await runValidate(context) : await runVerb(context);