@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 +9 -4
- package/dist/bin/sentinel.js +366 -156
- package/dist/{chunk-MOELAQPC.js → chunk-64H5HBPH.js} +580 -158
- package/dist/{chunk-43HQ6BVJ.js → chunk-J2USE3KV.js} +69 -19
- package/dist/{chunk-IL5PPLXE.js → chunk-JRZI5IIN.js} +1 -1
- package/dist/{chunk-IL5PPLXE.js.map → chunk-JRZI5IIN.js.map} +1 -1
- package/dist/{chunk-Y57KA4QS.js → chunk-LFNOBQZU.js} +2 -2
- package/dist/index.d.ts +48 -0
- package/dist/index.js +3 -3
- package/dist/roles/test/setup/jest-parity.js +2 -2
- package/dist/roles/test/setup/workspace-entry.js +2 -2
- package/dist/{validate-FRMENKHM.js → validate-TIPIECG6.js} +2 -2
- package/docs/format-adoption.md +40 -10
- package/docs/lint-adoption.md +73 -10
- package/docs/typescript-adoption.md +1 -0
- package/docs/typescript-traces.md +249 -0
- package/docs/using-sentinel.md +15 -3
- package/docs/validating-a-change.md +130 -0
- package/package.json +30 -23
- /package/dist/{chunk-Y57KA4QS.js.map → chunk-LFNOBQZU.js.map} +0 -0
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
|
|
59
|
-
the linter's autofix runs, then the formatter runs **last**
|
|
60
|
-
wrote.
|
|
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
|
|
package/dist/bin/sentinel.js
CHANGED
|
@@ -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-
|
|
14
|
+
} from "../chunk-64H5HBPH.js";
|
|
14
15
|
import {
|
|
15
16
|
resolveContext
|
|
16
|
-
} from "../chunk-
|
|
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-
|
|
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(
|
|
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
|
|
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);
|