@hublo/sentinel 1.5.0 → 1.5.1-alpha.1

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
@@ -55,9 +55,13 @@ sentinel --init --preset <react|nest|node|svelte> # every role sentinel ships
55
55
  pnpm install # fetch what --init declared, then commit
56
56
  ```
57
57
 
58
- With no target named, `--init` adopts every role, in the order that makes the result correct:
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.
58
+ With no target named, `--init` on a module that has adopted nothing adopts every role, in the
59
+ order that makes the result correct: the linter's autofix runs, then the formatter runs **last**
60
+ and formats everything every role wrote. On a module that has already adopted a role, the same
61
+ bare `--init` re-inits only the roles it has, read the way `--inspect` reports them, and says
62
+ which. Naming a target (`--init --build`) adopts just that role and leaves the rest alone, with
63
+ one pairing: lint and format are always adopted together, so `--init --lint` adopts oxfmt too.
64
+ `--init --all` adopts every role on any module.
61
65
 
62
66
  `--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
67
 
@@ -127,7 +131,7 @@ And the app's `package.json` scripts route every check through the one CLI (run
127
131
  **Step 3 — evolve the toolchain, in one central place:**
128
132
 
129
133
  - a **rule change** → bump the `@hublo/sentinel` version; the stubs already point at it, so there is **nothing to regenerate**;
130
- - a **structural change** (new tool, new preset, runner swap) → run `sentinel --init` once to refresh the stubs (sentinel tells you when this is needed).
134
+ - a **structural change** (new tool, new preset, runner swap) → run `sentinel --init` once to refresh the stubs (sentinel tells you when this is needed). On an adopted module it re-inits the roles the module has, and no others.
131
135
 
132
136
  **Step 4 — stay converged:** a drift guard in CI flags any module whose config quietly diverged from the shared source.
133
137
 
@@ -412,6 +416,7 @@ OPTIONS --module <name> from the root: scope to one module
412
416
  --ci from the root: affected only; non-zero exit on failure
413
417
  --fix auto-fix where applicable
414
418
  --dry-run preview a --init without writing
419
+ --rehearse with --dry-run: run the autofixes in a copy, name the source they rewrite
415
420
  --json machine-readable output (report / inspect / status / --dry-run)
416
421
  -- <tool options> --run only: pass the rest to the tool itself (one type)
417
422
 
@@ -8,19 +8,20 @@ import {
8
8
  describeFramework,
9
9
  dispatch,
10
10
  palette,
11
+ presetNotInstalledYet,
11
12
  registerAdapters,
12
13
  resolve
13
- } from "../chunk-MOELAQPC.js";
14
+ } from "../chunk-64H5HBPH.js";
14
15
  import {
15
16
  resolveContext
16
- } from "../chunk-43HQ6BVJ.js";
17
+ } from "../chunk-J2USE3KV.js";
17
18
  import {
18
19
  ensureWorkspacePrep,
19
20
  findWorkspaceRoot,
20
21
  inspectWorkspacePrep,
21
22
  readOwnVersion,
22
23
  readProjectPackageJson
23
- } from "../chunk-IL5PPLXE.js";
24
+ } from "../chunk-JRZI5IIN.js";
24
25
  import {
25
26
  LONG_RUNNING_TARGETS,
26
27
  OUTPUT_SHAPES,
@@ -35,157 +36,6 @@ import "../chunk-QXFCZON7.js";
35
36
  // bin/sentinel.ts
36
37
  import { program } from "commander";
37
38
 
38
- // src/roles/build/declined.ts
39
- import { existsSync, readFileSync } from "node:fs";
40
- import { join } from "node:path";
41
- var DEPRECATED_NX_BUILDER = "@nx/webpack:webpack";
42
- function buildsWithDeprecatedExecutor(cwd2) {
43
- const projectJson = join(cwd2, "project.json");
44
- if (!existsSync(projectJson)) return false;
45
- try {
46
- const project = JSON.parse(readFileSync(projectJson, "utf8"));
47
- return project.targets?.build?.executor === DEPRECATED_NX_BUILDER;
48
- } catch {
49
- return false;
50
- }
51
- }
52
- function explainDecline(cwd2) {
53
- if (buildConfigFile(cwd2) !== void 0) {
54
- return ` This module HAS a Vite config, so detection is the likely problem: the build toolchain is declared at the workspace root, which makes a front app detect as "node". Pass --preset to say what it is.`;
55
- }
56
- if (buildsWithDeprecatedExecutor(cwd2)) {
57
- return ` This module still builds through ${DEPRECATED_NX_BUILDER}, which Nx v24 removes. It has no Vite config yet, so there is nothing for --build to adopt: add one that imports @hublo/sentinel/build/nest, then run this again. See docs/build-adoption.md.`;
58
- }
59
- return ` This module has neither a Vite config nor an Nx build target, so it builds nothing and --build is out of scope for it. Nothing was written, and nothing is wrong.`;
60
- }
61
-
62
- // src/cli/run-init.ts
63
- function asMessage(error) {
64
- return error instanceof Error ? error.message : String(error);
65
- }
66
- async function runInit(ctx) {
67
- const { modules, scope } = resolveContext(ctx.cwd, { module: ctx.module, ci: false });
68
- const [module] = modules;
69
- if (scope === "all" || scope === "affected" || !module) {
70
- ctx.fail(
71
- "--init writes files: target one module (run from its directory, or pass --module). Adopting every module at once is intentionally not allowed \u2014 adopt gradually."
72
- );
73
- }
74
- const available = availableTargets();
75
- if (ctx.targetsExplicit) {
76
- const unwired = ctx.targets.filter((type) => !available.includes(type));
77
- if (unwired.length > 0) {
78
- ctx.fail(
79
- `target(s) not available yet: ${unwired.map((t) => `--${t}`).join(", ")}. Available now: ${available.map((t) => `--${t}`).join(", ") || "(none yet)"}.`
80
- );
81
- }
82
- }
83
- const toRun = ctx.targets.filter((type) => available.includes(type)).sort((a, b) => Number(a === "format") - Number(b === "format"));
84
- const outcomes = [];
85
- const record = (outcome) => {
86
- outcomes.push(outcome);
87
- };
88
- let worst = 0;
89
- for (const type of toRun) {
90
- try {
91
- const code = await dispatch({
92
- verb: ctx.verb,
93
- target: type,
94
- runner: ctx.runner,
95
- cwd: module.root,
96
- preset: ctx.preset,
97
- dryRun: ctx.dryRun,
98
- json: ctx.json,
99
- record
100
- });
101
- worst = Math.max(worst, code);
102
- } catch (error) {
103
- if (error instanceof PresetUnsupportedError) {
104
- if (ctx.targetsExplicit) {
105
- const hint = type === "build" ? explainDecline(module.root) : ` Pass --preset if it was detected wrongly (hoisted dependencies make detection fall back to "node").`;
106
- process.stderr.write(
107
- `
108
- sentinel (${type}): this module's preset is "${error.preset}", and --${type} has no adapter for it.${hint}
109
- `
110
- );
111
- worst = Math.max(worst, 1);
112
- }
113
- record({
114
- role: type,
115
- status: "unsupported",
116
- reason: `this module's preset is "${error.preset}", and --${type} has no adapter for it.`
117
- });
118
- continue;
119
- }
120
- process.stderr.write(`
121
- sentinel (${type}): ${asMessage(error)}
122
- `);
123
- record({ role: type, status: "failed", reason: asMessage(error) });
124
- worst = Math.max(worst, 1);
125
- }
126
- }
127
- let workspace = [];
128
- if (worst === 0) {
129
- const root = findWorkspaceRoot(module.root);
130
- if (root) {
131
- const changes = ensureWorkspacePrep({
132
- root,
133
- dryRun: ctx.dryRun,
134
- // Only when the FORMAT role actually adopted: a module still formatted by the root
135
- // must not be excluded from it.
136
- formattedModule: toRun.includes("format") ? module.root : void 0,
137
- // Same rule for the report directory: only when the STATIC ANALYSIS role adopted.
138
- analysedModule: toRun.includes("static-analysis")
139
- });
140
- workspace = changes;
141
- const prefix = ctx.dryRun ? " dry run: " : " ";
142
- for (const change of changes) process.stderr.write(`${prefix}${change}
143
- `);
144
- if (changes.length > 0 && !ctx.dryRun) {
145
- process.stderr.write(
146
- palette(process.stderr).dim(" run `pnpm install` to apply the workspace changes\n")
147
- );
148
- }
149
- }
150
- }
151
- if (ctx.json) {
152
- process.stdout.write(
153
- JSON.stringify(
154
- { dryRun: ctx.dryRun, module: module.name, roles: outcomes, workspace },
155
- null,
156
- 2
157
- ) + "\n"
158
- );
159
- }
160
- return worst;
161
- }
162
-
163
- // src/cli/run-validate.ts
164
- async function runValidate(ctx) {
165
- const { modules } = resolveContext(ctx.cwd, { module: ctx.module, ci: ctx.ci });
166
- const unsupported = ctx.targets.filter((target) => target !== "test");
167
- if (unsupported.length > 0) {
168
- const named = unsupported.map((target) => `--${target}`).join(", ");
169
- process.stdout.write(
170
- ` \xB7 ${named}: not available yet. \`--validate\` answers \`--test\` today, by recording a suite before a migration and comparing it after. Nothing was checked for ${named}.
171
- `
172
- );
173
- }
174
- if (!ctx.targets.includes("test")) return 1;
175
- const { validateModuleTests } = await import("../validate-FRMENKHM.js");
176
- const outcomes = modules.flatMap(
177
- (module) => validateModuleTests(module.name, module.root)
178
- );
179
- for (const outcome of outcomes) {
180
- const suite = outcome.suite === "main" ? "" : ` [${outcome.suite}]`;
181
- process.stdout.write(
182
- ` ${outcome.ok ? "\u2713" : "\u2717"} ${outcome.module}${suite} \u2014 ${outcome.message}
183
- `
184
- );
185
- }
186
- return outcomes.every((outcome) => outcome.ok) ? 0 : 1;
187
- }
188
-
189
39
  // src/core/orchestrate.ts
190
40
  async function analyse(params) {
191
41
  const results = [];
@@ -341,6 +191,355 @@ function generateSummaries(results, requestedTargets) {
341
191
  results: results.map(generateSummary)
342
192
  };
343
193
  }
194
+ async function adoptedTargets(params) {
195
+ const { results } = await analyse({
196
+ verb: "status",
197
+ targets: params.targets,
198
+ modules: [params.module],
199
+ preset: params.preset,
200
+ runner: params.runner,
201
+ targetsExplicit: false,
202
+ ci: false,
203
+ fix: false
204
+ });
205
+ return results.filter((row) => row.data?.adopted === true).map((row) => row.target);
206
+ }
207
+
208
+ // src/core/rehearse.ts
209
+ import { createHash } from "node:crypto";
210
+ import {
211
+ cpSync,
212
+ existsSync,
213
+ lstatSync,
214
+ mkdirSync,
215
+ mkdtempSync,
216
+ readdirSync,
217
+ readFileSync,
218
+ realpathSync,
219
+ rmSync,
220
+ symlinkSync,
221
+ writeFileSync
222
+ } from "node:fs";
223
+ import { tmpdir } from "node:os";
224
+ import { basename, dirname, join, relative, sep } from "node:path";
225
+ var NOT_COPIED = /* @__PURE__ */ new Set(["node_modules", ".git"]);
226
+ function contents(root) {
227
+ const found = /* @__PURE__ */ new Map();
228
+ const queue = [root];
229
+ while (queue.length > 0) {
230
+ const dir = queue.pop();
231
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
232
+ const path = join(dir, entry.name);
233
+ if (entry.isDirectory()) {
234
+ if (!NOT_COPIED.has(entry.name)) queue.push(path);
235
+ continue;
236
+ }
237
+ if (!entry.isFile()) continue;
238
+ found.set(relative(root, path), createHash("sha1").update(readFileSync(path)).digest("hex"));
239
+ }
240
+ }
241
+ return found;
242
+ }
243
+ function stage(moduleDir, scratch) {
244
+ const root = findWorkspaceRoot(moduleDir);
245
+ const segments = root === void 0 ? [basename(moduleDir)] : relative(root, moduleDir).split(sep).filter((segment) => segment !== "");
246
+ let real = root ?? dirname(moduleDir);
247
+ let shadow = scratch;
248
+ for (const segment of segments) {
249
+ for (const entry of root === void 0 ? [] : readdirSync(real)) {
250
+ if (entry === segment || entry === ".git") continue;
251
+ symlinkSync(join(real, entry), join(shadow, entry));
252
+ }
253
+ real = join(real, segment);
254
+ shadow = join(shadow, segment);
255
+ mkdirSync(shadow);
256
+ }
257
+ rmSync(shadow, { recursive: true, force: true });
258
+ cpSync(moduleDir, shadow, {
259
+ recursive: true,
260
+ filter: (source) => source === moduleDir || !NOT_COPIED.has(basename(source)) && !lstatSync(source).isSymbolicLink()
261
+ });
262
+ if (existsSync(join(moduleDir, "node_modules"))) {
263
+ symlinkSync(join(moduleDir, "node_modules"), join(shadow, "node_modules"), "dir");
264
+ }
265
+ return shadow;
266
+ }
267
+ async function rehearseAfterInit(moduleDir, roles) {
268
+ const rehearsing = roles.filter((role) => role.adapter.rehearseAfterInit !== void 0);
269
+ if (rehearsing.length === 0) return [];
270
+ const scratch = realpathSync(mkdtempSync(join(tmpdir(), "sentinel-rehearsal-")));
271
+ try {
272
+ let copy;
273
+ try {
274
+ copy = stage(moduleDir, scratch);
275
+ } catch (error) {
276
+ const failure = error instanceof Error ? error.message : String(error);
277
+ return rehearsing.map((role) => ({ target: role.target, rewrites: [], failure }));
278
+ }
279
+ const planned = /* @__PURE__ */ new Set();
280
+ for (const role of roles) {
281
+ for (const file of role.files) {
282
+ planned.add(file.path);
283
+ const target = join(copy, file.path);
284
+ if (file.deleted) {
285
+ rmSync(target, { force: true });
286
+ continue;
287
+ }
288
+ mkdirSync(dirname(target), { recursive: true });
289
+ writeFileSync(target, file.after);
290
+ }
291
+ }
292
+ const results = [];
293
+ for (const role of rehearsing) {
294
+ const before = contents(copy);
295
+ const failure = await role.adapter.rehearseAfterInit?.(
296
+ {
297
+ module: basename(moduleDir),
298
+ cwd: copy,
299
+ preset: role.preset,
300
+ ci: false,
301
+ fix: true,
302
+ written: role.files.filter((file) => !file.deleted).map((file) => file.path)
303
+ },
304
+ moduleDir
305
+ );
306
+ const after = contents(copy);
307
+ const rewrites = [...after].filter(([path, hash]) => before.get(path) !== hash && !planned.has(path)).map(([path]) => path).sort();
308
+ results.push({
309
+ target: role.target,
310
+ rewrites,
311
+ // The tool names files by the path it read them from, which is the copy. Said as the
312
+ // module's own paths: the copy is gone by the time anyone reads this.
313
+ ...failure === void 0 ? {} : { failure: failure.replaceAll(`${copy}/`, "").replaceAll(copy, ".") }
314
+ });
315
+ }
316
+ return results;
317
+ } finally {
318
+ rmSync(scratch, { recursive: true, force: true });
319
+ }
320
+ }
321
+
322
+ // src/roles/build/declined.ts
323
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
324
+ import { join as join2 } from "node:path";
325
+ var DEPRECATED_NX_BUILDER = "@nx/webpack:webpack";
326
+ function buildsWithDeprecatedExecutor(cwd2) {
327
+ const projectJson = join2(cwd2, "project.json");
328
+ if (!existsSync2(projectJson)) return false;
329
+ try {
330
+ const project = JSON.parse(readFileSync2(projectJson, "utf8"));
331
+ return project.targets?.build?.executor === DEPRECATED_NX_BUILDER;
332
+ } catch {
333
+ return false;
334
+ }
335
+ }
336
+ function explainDecline(cwd2) {
337
+ if (buildConfigFile(cwd2) !== void 0) {
338
+ return ` This module HAS a Vite config, so detection is the likely problem: the build toolchain is declared at the workspace root, which makes a front app detect as "node". Pass --preset to say what it is.`;
339
+ }
340
+ if (buildsWithDeprecatedExecutor(cwd2)) {
341
+ return ` This module still builds through ${DEPRECATED_NX_BUILDER}, which Nx v24 removes. It has no Vite config yet, so there is nothing for --build to adopt: add one that imports @hublo/sentinel/build/nest, then run this again. See docs/build-adoption.md.`;
342
+ }
343
+ return ` This module has neither a Vite config nor an Nx build target, so it builds nothing and --build is out of scope for it. Nothing was written, and nothing is wrong.`;
344
+ }
345
+
346
+ // src/cli/run-init.ts
347
+ function asMessage(error) {
348
+ return error instanceof Error ? error.message : String(error);
349
+ }
350
+ var ADOPTED_TOGETHER = ["lint", "format"];
351
+ function pairedTargets(named, available) {
352
+ if (!named.some((type) => ADOPTED_TOGETHER.includes(type))) return [];
353
+ return ADOPTED_TOGETHER.filter((type) => available.includes(type) && !named.includes(type));
354
+ }
355
+ var NAMED_REWRITES = 10;
356
+ var AUTOFIX_TOOL = { lint: "linter", format: "formatter" };
357
+ function describeUnrehearsed(target) {
358
+ 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)`;
359
+ }
360
+ function describeRehearsal(target, rewrites, failure) {
361
+ const pass = `the ${target} autofix`;
362
+ const said = failure === void 0 ? "" : ` (it also reported: ${failure})`;
363
+ if (rewrites.length === 0) {
364
+ if (failure === void 0) return `${pass} would rewrite nothing beyond the files above`;
365
+ if (presetNotInstalledYet(failure)) {
366
+ 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`;
367
+ }
368
+ return `could not rehearse ${pass}, so what it rewrites is not known: ${failure}`;
369
+ }
370
+ const named = rewrites.slice(0, NAMED_REWRITES).join(", ");
371
+ const rest = rewrites.length - NAMED_REWRITES;
372
+ return `${pass} would rewrite ${rewrites.length} file${rewrites.length === 1 ? "" : "s"} beyond the ones above: ${rest > 0 ? `${named} and ${rest} more` : named}${said}`;
373
+ }
374
+ async function rolesToInit(ctx, module, available) {
375
+ const requested = ctx.targets.filter((type) => available.includes(type));
376
+ if (ctx.targetsExplicit || ctx.allTargets) return requested;
377
+ const adopted = await adoptedTargets({
378
+ module,
379
+ targets: requested,
380
+ preset: ctx.preset,
381
+ runner: ctx.runner
382
+ });
383
+ if (adopted.length === 0) return requested;
384
+ process.stderr.write(
385
+ palette(process.stderr).dim(
386
+ ` re-initializing the roles this module has adopted (${adopted.map((t) => `--${t}`).join(", ")}); to adopt another, name it (sentinel --init --<role>), or pass --all for every role
387
+ `
388
+ )
389
+ );
390
+ return adopted;
391
+ }
392
+ async function runInit(ctx) {
393
+ const { modules, scope } = resolveContext(ctx.cwd, { module: ctx.module, ci: false });
394
+ const [module] = modules;
395
+ if (scope === "all" || scope === "affected" || !module) {
396
+ ctx.fail(
397
+ "--init writes files: target one module (run from its directory, or pass --module). Adopting every module at once is intentionally not allowed \u2014 adopt gradually."
398
+ );
399
+ }
400
+ const available = availableTargets();
401
+ if (ctx.targetsExplicit) {
402
+ const unwired = ctx.targets.filter((type) => !available.includes(type));
403
+ if (unwired.length > 0) {
404
+ ctx.fail(
405
+ `target(s) not available yet: ${unwired.map((t) => `--${t}`).join(", ")}. Available now: ${available.map((t) => `--${t}`).join(", ") || "(none yet)"}.`
406
+ );
407
+ }
408
+ }
409
+ const named = await rolesToInit(ctx, module, available);
410
+ const paired = pairedTargets(named, available);
411
+ const toRun = [...named, ...paired].sort(
412
+ (a, b) => Number(a === "format") - Number(b === "format")
413
+ );
414
+ for (const type of paired) {
415
+ process.stderr.write(
416
+ palette(process.stderr).dim(
417
+ ` adopting --${type} too: lint and format are adopted together, see the lint adoption guide
418
+ `
419
+ )
420
+ );
421
+ }
422
+ const outcomes = [];
423
+ const record = (outcome) => {
424
+ outcomes.push(outcome);
425
+ };
426
+ const planned = [];
427
+ let worst = 0;
428
+ for (const type of toRun) {
429
+ try {
430
+ const code = await dispatch({
431
+ verb: ctx.verb,
432
+ target: type,
433
+ runner: ctx.runner,
434
+ cwd: module.root,
435
+ preset: ctx.preset,
436
+ dryRun: ctx.dryRun,
437
+ json: ctx.json,
438
+ record,
439
+ planned: (role) => planned.push(role)
440
+ });
441
+ worst = Math.max(worst, code);
442
+ } catch (error) {
443
+ if (error instanceof PresetUnsupportedError) {
444
+ if (ctx.targetsExplicit && !paired.includes(type)) {
445
+ const hint = type === "build" ? explainDecline(module.root) : ` Pass --preset if it was detected wrongly (hoisted dependencies make detection fall back to "node").`;
446
+ process.stderr.write(
447
+ `
448
+ sentinel (${type}): this module's preset is "${error.preset}", and --${type} has no adapter for it.${hint}
449
+ `
450
+ );
451
+ worst = Math.max(worst, 1);
452
+ }
453
+ record({
454
+ role: type,
455
+ status: "unsupported",
456
+ reason: `this module's preset is "${error.preset}", and --${type} has no adapter for it.`
457
+ });
458
+ continue;
459
+ }
460
+ process.stderr.write(`
461
+ sentinel (${type}): ${asMessage(error)}
462
+ `);
463
+ record({ role: type, status: "failed", reason: asMessage(error) });
464
+ worst = Math.max(worst, 1);
465
+ }
466
+ }
467
+ if (ctx.dryRun && worst === 0 && !ctx.rehearse && !ctx.json) {
468
+ for (const { target } of planned.filter((role) => role.adapter.rehearseAfterInit)) {
469
+ process.stderr.write(` dry run: ${describeUnrehearsed(target)}
470
+ `);
471
+ }
472
+ }
473
+ if (ctx.dryRun && worst === 0 && ctx.rehearse) {
474
+ for (const { target, rewrites, failure } of await rehearseAfterInit(module.root, planned)) {
475
+ const outcome = outcomes.find((entry) => entry.role === target);
476
+ if (outcome !== void 0) outcome.rewrites = rewrites;
477
+ if (ctx.json) continue;
478
+ process.stderr.write(` dry run: ${describeRehearsal(target, rewrites, failure)}
479
+ `);
480
+ }
481
+ }
482
+ let workspace = [];
483
+ if (worst === 0) {
484
+ const root = findWorkspaceRoot(module.root);
485
+ if (root) {
486
+ const changes = ensureWorkspacePrep({
487
+ root,
488
+ dryRun: ctx.dryRun,
489
+ // Only when the FORMAT role actually adopted: a module still formatted by the root
490
+ // must not be excluded from it.
491
+ formattedModule: toRun.includes("format") ? module.root : void 0,
492
+ // Same rule for the report directory: only when the STATIC ANALYSIS role adopted.
493
+ analysedModule: toRun.includes("static-analysis")
494
+ });
495
+ workspace = changes;
496
+ const prefix = ctx.dryRun ? " dry run: " : " ";
497
+ for (const change of changes) process.stderr.write(`${prefix}${change}
498
+ `);
499
+ if (changes.length > 0 && !ctx.dryRun) {
500
+ process.stderr.write(
501
+ palette(process.stderr).dim(" run `pnpm install` to apply the workspace changes\n")
502
+ );
503
+ }
504
+ }
505
+ }
506
+ if (ctx.json) {
507
+ process.stdout.write(
508
+ JSON.stringify(
509
+ { dryRun: ctx.dryRun, module: module.name, roles: outcomes, workspace },
510
+ null,
511
+ 2
512
+ ) + "\n"
513
+ );
514
+ }
515
+ return worst;
516
+ }
517
+
518
+ // src/cli/run-validate.ts
519
+ async function runValidate(ctx) {
520
+ const { modules } = resolveContext(ctx.cwd, { module: ctx.module, ci: ctx.ci });
521
+ const unsupported = ctx.targets.filter((target) => target !== "test");
522
+ if (unsupported.length > 0) {
523
+ const named = unsupported.map((target) => `--${target}`).join(", ");
524
+ process.stdout.write(
525
+ ` \xB7 ${named}: not available yet. \`--validate\` answers \`--test\` today, by recording a suite before a migration and comparing it after. Nothing was checked for ${named}.
526
+ `
527
+ );
528
+ }
529
+ if (!ctx.targets.includes("test")) return 1;
530
+ const { validateModuleTests } = await import("../validate-TIPIECG6.js");
531
+ const outcomes = modules.flatMap(
532
+ (module) => validateModuleTests(module.name, module.root)
533
+ );
534
+ for (const outcome of outcomes) {
535
+ const suite = outcome.suite === "main" ? "" : ` [${outcome.suite}]`;
536
+ process.stdout.write(
537
+ ` ${outcome.ok ? "\u2713" : "\u2717"} ${outcome.module}${suite} \u2014 ${outcome.message}
538
+ `
539
+ );
540
+ }
541
+ return outcomes.every((outcome) => outcome.ok) ? 0 : 1;
542
+ }
344
543
 
345
544
  // src/core/render.ts
346
545
  var HEAD_KEYS = /* @__PURE__ */ new Set([
@@ -627,7 +826,10 @@ program.name("sentinel").description("One CLI that guards code health: presets,
627
826
  ).option("--report", "deprecated: use --run --json").option("--status", "deprecated: use --inspect").option(
628
827
  "--module <name>",
629
828
  "from the workspace root: scope to one module (omit = all; inside a module dir, drop this)"
630
- ).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(
829
+ ).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(
830
+ "--rehearse",
831
+ "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)"
832
+ ).option("--json", "machine-readable output, for every verb").option(
631
833
  "--output <shape>",
632
834
  "output shape for a role that writes reports (console, json, html, markdown); console by default"
633
835
  ).option(
@@ -650,7 +852,8 @@ program.name("sentinel").description("One CLI that guards code health: presets,
650
852
  // Deliberately not enumerated here. A hand-written list drifts, and this one did: it
651
853
  // still said "lint, format, typescript" after the build role shipped, so the help told
652
854
  // adopters the build was not covered when it was. "Available now", below, is derived.
653
- " sentinel --init --preset react # adopt every available role (see below)",
855
+ " sentinel --init --preset react # a new module: adopt every available role",
856
+ " sentinel --init # an adopted module: re-init the roles it has",
654
857
  " sentinel --init --typescript --preset react # set up the current module + workspace",
655
858
  " sentinel --run --typescript -- --noImplicitAny # ask the tool a question of your own"
656
859
  ].join("\n")
@@ -742,6 +945,11 @@ if (!Number.isInteger(maxDiagnostics) || maxDiagnostics < 0) {
742
945
  if (opts.dryRun && verb !== "init") {
743
946
  program.error("--dry-run only applies to --init (the read verbs never write).");
744
947
  }
948
+ if (opts.rehearse && !opts.dryRun) {
949
+ program.error(
950
+ "--rehearse only applies to --init --dry-run: the real --init runs the autofix passes themselves."
951
+ );
952
+ }
745
953
  if (toolArgs.length > 0) {
746
954
  if (verb !== "run") {
747
955
  program.error(
@@ -769,6 +977,7 @@ async function main() {
769
977
  cwd,
770
978
  targets,
771
979
  targetsExplicit,
980
+ allTargets: Boolean(opts.all),
772
981
  preset,
773
982
  toolArgs,
774
983
  maxDiagnostics,
@@ -781,6 +990,7 @@ async function main() {
781
990
  // a role that writes reports: two ways to ask for the same thing would be a trap.
782
991
  output: parseOutput(opts.output, Boolean(opts.json)) ?? (opts.json ? "json" : void 0),
783
992
  dryRun: Boolean(opts.dryRun),
993
+ rehearse: Boolean(opts.rehearse),
784
994
  fail: (message) => program.error(message)
785
995
  };
786
996
  const exitCode = verb === "init" ? await runInit(context) : verb === "validate" ? await runValidate(context) : await runVerb(context);