@hublo/sentinel 0.1.0-alpha.7 → 0.1.0-alpha.9
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 +36 -6
- package/dist/bin/sentinel.js +215 -27
- package/dist/bin/sentinel.js.map +1 -1
- package/dist/{chunk-G7NVAZSZ.js → chunk-NQ7GNHFT.js} +168 -61
- package/dist/chunk-NQ7GNHFT.js.map +1 -0
- package/dist/index.d.ts +34 -11
- package/dist/index.js +1 -1
- package/package.json +4 -6
- package/dist/chunk-G7NVAZSZ.js.map +0 -1
package/README.md
CHANGED
|
@@ -43,6 +43,8 @@ A large monorepo accumulates:
|
|
|
43
43
|
|
|
44
44
|
sentinel writes **standard config files** into a project (each just `extends` a sentinel preset) and runs the checks. Your editor and the tools read those **normal files natively**, they never call sentinel at runtime, so nothing is coupled to it or brittle.
|
|
45
45
|
|
|
46
|
+
> **Shipped today:** only the **TypeScript** tool, so `--update` writes the `tsconfig` stub, and `--run`/`--report`/`--status` work for `--typescript`. The `eslint.config.js` / `--lint` / `--test` snippets below illustrate the end state; those subpaths (`@hublo/sentinel/lint/*`, …) land with their tool ticket.
|
|
47
|
+
|
|
46
48
|
**Step 1 — put a module on sentinel** (once per module, by a dev; the files are committed):
|
|
47
49
|
|
|
48
50
|
```bash
|
|
@@ -99,6 +101,18 @@ And the app's `package.json` scripts route every check through the one CLI (run
|
|
|
99
101
|
|
|
100
102
|
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.
|
|
101
103
|
|
|
104
|
+
## Requirements & installing
|
|
105
|
+
|
|
106
|
+
**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.
|
|
107
|
+
|
|
108
|
+
**Try it without installing.** A one-off run needs no auth and touches nothing:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pnpm dlx @hublo/sentinel@<exact-version> --inspect --typescript --module <name>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Installing a pre-release (`minimumReleaseAge`).** The monorepo enforces a 3-day `minimumReleaseAge` supply-chain gate (a freshly published version cannot be installed until it has aged 3 days). A brand-new `alpha` therefore cannot be added yet, so while testing pre-releases you either exclude the package (`pnpm-workspace.yaml` → `minimumReleaseAgeExclude`) or install with `--config.minimumReleaseAge=0`. This is a deliberate protection, not a bug: **always pin the exact version** (`@hublo/sentinel@0.1.0-alpha.9`) rather than `@latest`, so a run is reproducible and the gate stays meaningful.
|
|
115
|
+
|
|
102
116
|
## Architecture: `target → runner → flavour`
|
|
103
117
|
|
|
104
118
|
Every check is described by three layers:
|
|
@@ -244,6 +258,7 @@ sentinel <verb> [type] [options]
|
|
|
244
258
|
VERBS --run execute the target's tool
|
|
245
259
|
--inspect show the resolved configuration (incl. deferred rules)
|
|
246
260
|
--report metrics and health
|
|
261
|
+
--status adoption + conformity (coverage + drift), read from configs
|
|
247
262
|
--update generate/apply the config stubs (writes; one module only)
|
|
248
263
|
|
|
249
264
|
TYPES --lint --format --typescript --build --test
|
|
@@ -259,17 +274,33 @@ OPTIONS --module <name> from the root: scope to one module
|
|
|
259
274
|
--ci from the root: affected only; non-zero exit on failure
|
|
260
275
|
--fix auto-fix where applicable
|
|
261
276
|
--dry-run preview a --update without writing
|
|
277
|
+
--json machine-readable output (report / inspect / status / --dry-run)
|
|
262
278
|
|
|
263
279
|
EXAMPLES sentinel --run --typescript # in a module → that module
|
|
264
280
|
sentinel --report --typescript --module bff-admin # from root → one module
|
|
265
281
|
sentinel --report # from root → all types, all modules
|
|
266
|
-
sentinel --
|
|
282
|
+
sentinel --status --typescript # from root → adoption coverage
|
|
283
|
+
sentinel --status --ci # from root → fail CI on drift
|
|
267
284
|
sentinel --update --typescript --flavour react # write stubs for the current module
|
|
268
285
|
```
|
|
269
286
|
|
|
270
|
-
`--run`/`--inspect`/`--report` share one context rule (developer from a
|
|
271
|
-
from the root for a name / affected / all); `--update` writes, so it targets
|
|
272
|
-
module only (adopting every module at once is refused, adopt gradually).
|
|
287
|
+
`--run`/`--inspect`/`--report`/`--status` share one context rule (developer from a
|
|
288
|
+
module, or from the root for a name / affected / all); `--update` writes, so it targets
|
|
289
|
+
one module only (adopting every module at once is refused, adopt gradually).
|
|
290
|
+
|
|
291
|
+
**`--status` — adoption + conformity.** A cheap, workspace-wide read (no tool run, no
|
|
292
|
+
flavour guessing): for each module it reads the committed `tsconfig` `extends` chain and
|
|
293
|
+
reports whether it is **adopted** (extends a sentinel preset), which preset, and whether
|
|
294
|
+
it is **conformant** (drift-free, a re-`update` would change nothing), plus a coverage
|
|
295
|
+
footer. This is the **drift guard** as a command, `--status --ci` exits non-zero when an
|
|
296
|
+
adopted module has drifted.
|
|
297
|
+
|
|
298
|
+
```
|
|
299
|
+
✓ host-admin (react) typescript — adopted (react) conformant
|
|
300
|
+
✗ some-bff (nest) typescript — adopted (nest) drift: strict, target
|
|
301
|
+
· legacy-app (node) typescript — not adopted
|
|
302
|
+
coverage: 12/40 adopted · 11/12 conformant · 1 drifted
|
|
303
|
+
```
|
|
273
304
|
|
|
274
305
|
## Repository layout
|
|
275
306
|
|
|
@@ -293,8 +324,7 @@ src/
|
|
|
293
324
|
runners/ # one runner per sub-tool (duplication, complexity, ...)
|
|
294
325
|
shared/ # reusable utils (package-json, deep-merge, text)
|
|
295
326
|
tests/ # unit tests + tests/e2e (runs the built dist binary)
|
|
296
|
-
.
|
|
297
|
-
.github/workflows/ # ci.yml (PR checks) + release.yml (changesets publish)
|
|
327
|
+
.github/workflows/ # ci.yml (PR checks) + publish.yml (manual, version-input publish)
|
|
298
328
|
```
|
|
299
329
|
|
|
300
330
|
Subpath exports (in `package.json`) expose presets to consumers. Shipped today:
|
package/dist/bin/sentinel.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
availableTargets,
|
|
4
|
+
describeFramework,
|
|
4
5
|
dispatch,
|
|
5
6
|
palette,
|
|
6
7
|
readNxProjectName,
|
|
@@ -9,7 +10,7 @@ import {
|
|
|
9
10
|
registerAdapters,
|
|
10
11
|
resolve,
|
|
11
12
|
resolveBin
|
|
12
|
-
} from "../chunk-
|
|
13
|
+
} from "../chunk-NQ7GNHFT.js";
|
|
13
14
|
|
|
14
15
|
// bin/sentinel.ts
|
|
15
16
|
import { program } from "commander";
|
|
@@ -105,7 +106,7 @@ function resolveContext(cwd2, opts2) {
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
// src/core/domain.ts
|
|
108
|
-
var VERBS = ["run", "inspect", "update", "report"];
|
|
109
|
+
var VERBS = ["run", "inspect", "update", "report", "status"];
|
|
109
110
|
var TARGETS = [
|
|
110
111
|
"lint",
|
|
111
112
|
"format",
|
|
@@ -124,19 +125,45 @@ async function analyse(params) {
|
|
|
124
125
|
let worstCode = 0;
|
|
125
126
|
let done = 0;
|
|
126
127
|
for (const module of params.modules) {
|
|
127
|
-
|
|
128
|
+
let flavour2;
|
|
129
|
+
if (params.flavour) {
|
|
130
|
+
flavour2 = params.flavour;
|
|
131
|
+
} else {
|
|
132
|
+
const detection = describeFramework(readProjectPackageJson(module.root));
|
|
133
|
+
flavour2 = detection.flavour;
|
|
134
|
+
if (detection.ambiguous) {
|
|
135
|
+
const warn = palette(process.stderr);
|
|
136
|
+
process.stderr.write(
|
|
137
|
+
warn.warn(
|
|
138
|
+
`sentinel: ${module.name}: flavour is ambiguous, detected "${flavour2}" (from ${detection.source}), also found ${detection.conflicts.join(", ")}. Pass --flavour to be explicit.`
|
|
139
|
+
) + "\n"
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
128
143
|
const ctx = {
|
|
129
144
|
module: module.name,
|
|
130
145
|
cwd: module.root,
|
|
131
146
|
flavour: flavour2,
|
|
132
147
|
ci: params.ci,
|
|
133
|
-
fix: params.fix
|
|
148
|
+
fix: params.fix,
|
|
149
|
+
maxDiagnostics: params.maxDiagnostics
|
|
134
150
|
};
|
|
135
151
|
for (const target of params.targets) {
|
|
136
152
|
let adapter;
|
|
137
153
|
try {
|
|
138
154
|
adapter = resolve(target, flavour2, params.runner);
|
|
139
|
-
} catch {
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (params.targetsExplicit) {
|
|
157
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
158
|
+
results.push({
|
|
159
|
+
project: module.name,
|
|
160
|
+
target,
|
|
161
|
+
flavour: flavour2,
|
|
162
|
+
ok: false,
|
|
163
|
+
status: "unsupported",
|
|
164
|
+
data: { reason }
|
|
165
|
+
});
|
|
166
|
+
}
|
|
140
167
|
continue;
|
|
141
168
|
}
|
|
142
169
|
try {
|
|
@@ -150,7 +177,14 @@ async function analyse(params) {
|
|
|
150
177
|
);
|
|
151
178
|
}
|
|
152
179
|
const result = await adapter.run(ctx);
|
|
153
|
-
results.push({
|
|
180
|
+
results.push({
|
|
181
|
+
project: module.name,
|
|
182
|
+
target,
|
|
183
|
+
flavour: flavour2,
|
|
184
|
+
ok: result.ok,
|
|
185
|
+
status: result.ok ? "ok" : "failed",
|
|
186
|
+
data: {}
|
|
187
|
+
});
|
|
154
188
|
worstCode = Math.max(worstCode, result.code);
|
|
155
189
|
} else if (params.verb === "report") {
|
|
156
190
|
const result = await adapter.report(ctx);
|
|
@@ -159,16 +193,43 @@ async function analyse(params) {
|
|
|
159
193
|
target,
|
|
160
194
|
flavour: flavour2,
|
|
161
195
|
ok: result.ok,
|
|
196
|
+
status: result.ok ? "ok" : "failed",
|
|
162
197
|
data: result.metrics ?? {}
|
|
163
198
|
});
|
|
164
199
|
worstCode = Math.max(worstCode, result.code);
|
|
165
|
-
} else {
|
|
200
|
+
} else if (params.verb === "inspect") {
|
|
166
201
|
const config = await adapter.inspect(ctx);
|
|
167
|
-
results.push({
|
|
202
|
+
results.push({
|
|
203
|
+
project: module.name,
|
|
204
|
+
target,
|
|
205
|
+
flavour: flavour2,
|
|
206
|
+
ok: true,
|
|
207
|
+
status: "ok",
|
|
208
|
+
data: config
|
|
209
|
+
});
|
|
210
|
+
} else {
|
|
211
|
+
const status = await adapter.status(ctx);
|
|
212
|
+
const ok = !status.adopted || status.conformant;
|
|
213
|
+
results.push({
|
|
214
|
+
project: module.name,
|
|
215
|
+
target,
|
|
216
|
+
flavour: flavour2,
|
|
217
|
+
ok,
|
|
218
|
+
status: ok ? "ok" : "failed",
|
|
219
|
+
data: status
|
|
220
|
+
});
|
|
221
|
+
if (!ok) worstCode = Math.max(worstCode, 1);
|
|
168
222
|
}
|
|
169
223
|
} catch (error) {
|
|
170
224
|
const message = error instanceof Error ? error.message : String(error);
|
|
171
|
-
results.push({
|
|
225
|
+
results.push({
|
|
226
|
+
project: module.name,
|
|
227
|
+
target,
|
|
228
|
+
flavour: flavour2,
|
|
229
|
+
ok: false,
|
|
230
|
+
status: "failed",
|
|
231
|
+
data: { error: message }
|
|
232
|
+
});
|
|
172
233
|
worstCode = Math.max(worstCode, 1);
|
|
173
234
|
}
|
|
174
235
|
}
|
|
@@ -177,16 +238,34 @@ async function analyse(params) {
|
|
|
177
238
|
return { results, worstCode };
|
|
178
239
|
}
|
|
179
240
|
function generateSummary(result) {
|
|
180
|
-
const { project, target, flavour: flavour2, ok, data } = result;
|
|
241
|
+
const { project, target, flavour: flavour2, ok, status, data } = result;
|
|
181
242
|
const details = data && typeof data === "object" ? data : { value: data };
|
|
182
|
-
return { project, target, flavour: flavour2, ok, ...details };
|
|
243
|
+
return { project, target, flavour: flavour2, ok, status, ...details };
|
|
183
244
|
}
|
|
184
|
-
function generateSummaries(results) {
|
|
185
|
-
|
|
245
|
+
function generateSummaries(results, requestedTargets) {
|
|
246
|
+
const available = new Set(availableTargets());
|
|
247
|
+
return {
|
|
248
|
+
schemaVersion: 2,
|
|
249
|
+
executed: requestedTargets.filter((t) => available.has(t)),
|
|
250
|
+
skipped: requestedTargets.filter((t) => !available.has(t)),
|
|
251
|
+
results: results.map(generateSummary)
|
|
252
|
+
};
|
|
186
253
|
}
|
|
187
254
|
|
|
188
255
|
// src/core/render.ts
|
|
189
|
-
var HEAD_KEYS = /* @__PURE__ */ new Set([
|
|
256
|
+
var HEAD_KEYS = /* @__PURE__ */ new Set([
|
|
257
|
+
"project",
|
|
258
|
+
"module",
|
|
259
|
+
"target",
|
|
260
|
+
"flavour",
|
|
261
|
+
"ok",
|
|
262
|
+
"status",
|
|
263
|
+
"reason",
|
|
264
|
+
// The structured diagnostics are for `--json` consumers; the human row stays the concise
|
|
265
|
+
// `errors=N` line rather than dumping every diagnostic inline.
|
|
266
|
+
"diagnostics",
|
|
267
|
+
"diagnosticsTruncated"
|
|
268
|
+
]);
|
|
190
269
|
function isDeferredRules(value) {
|
|
191
270
|
return Array.isArray(value) && value.every((entry) => typeof entry === "object" && entry !== null && "rule" in entry);
|
|
192
271
|
}
|
|
@@ -194,9 +273,16 @@ function renderValue(key, value, p) {
|
|
|
194
273
|
if ((key === "errors" || key === "implicitAny") && typeof value === "number") {
|
|
195
274
|
return value > 0 ? p.fail(String(value)) : p.ok(String(value));
|
|
196
275
|
}
|
|
276
|
+
if (key === "implicitAny" && value === "deferred") return p.warn("deferred");
|
|
197
277
|
return typeof value === "string" ? value : JSON.stringify(value);
|
|
198
278
|
}
|
|
279
|
+
function renderUnsupportedRow(item, p) {
|
|
280
|
+
const reason = typeof item.reason === "string" ? ` ${p.dim(`(${item.reason})`)}` : "";
|
|
281
|
+
const head = ` ${p.dim("\xB7")} ${p.strong(String(item.project))} ${p.dim(`(${item.flavour})`)} ${item.target}`;
|
|
282
|
+
return `${head} ${p.dim("\u2014")} ${p.warn("unsupported")}${reason}`;
|
|
283
|
+
}
|
|
199
284
|
function renderSummary(item, p) {
|
|
285
|
+
if (item.status === "unsupported") return renderUnsupportedRow(item, p);
|
|
200
286
|
const ok = item.ok === true;
|
|
201
287
|
const mark = ok ? p.ok("\u2713") : p.fail("\u2717");
|
|
202
288
|
const head = ` ${mark} ${p.strong(String(item.project))} ${p.dim(`(${item.flavour})`)} ${item.target}`;
|
|
@@ -212,22 +298,82 @@ function renderSummary(item, p) {
|
|
|
212
298
|
}
|
|
213
299
|
return lines.join("\n");
|
|
214
300
|
}
|
|
301
|
+
function statusCoverage(items) {
|
|
302
|
+
const relevant = items.filter((i) => i.status !== "unsupported");
|
|
303
|
+
const adopted = relevant.filter((i) => i.adopted === true);
|
|
304
|
+
const conformant = adopted.filter((i) => i.conformant === true);
|
|
305
|
+
return {
|
|
306
|
+
total: relevant.length,
|
|
307
|
+
adopted: adopted.length,
|
|
308
|
+
conformant: conformant.length,
|
|
309
|
+
drifted: adopted.length - conformant.length
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function presetShort(preset) {
|
|
313
|
+
return typeof preset === "string" ? preset.replace("@hublo/sentinel/tsconfig/", "") : "?";
|
|
314
|
+
}
|
|
315
|
+
function renderStatusRow(item, p) {
|
|
316
|
+
if (item.status === "unsupported") return renderUnsupportedRow(item, p);
|
|
317
|
+
const adopted = item.adopted === true;
|
|
318
|
+
const conformant = item.conformant === true;
|
|
319
|
+
const mark = !adopted ? p.dim("\xB7") : conformant ? p.ok("\u2713") : p.fail("\u2717");
|
|
320
|
+
const head = ` ${mark} ${p.strong(String(item.project))} ${p.dim(`(${item.flavour})`)} ${item.target}`;
|
|
321
|
+
if (!adopted) return `${head} ${p.dim("\u2014 not adopted")}`;
|
|
322
|
+
if (conformant) {
|
|
323
|
+
return `${head} ${p.dim("\u2014")} ${p.ok("adopted")} ${p.dim(`(${presetShort(item.preset)}) conformant`)}`;
|
|
324
|
+
}
|
|
325
|
+
const drift = Array.isArray(item.drift) ? item.drift.join(", ") : "";
|
|
326
|
+
return `${head} ${p.dim("\u2014")} ${p.ok("adopted")} ${p.dim(`(${presetShort(item.preset)})`)} ${p.fail(`drift: ${drift}`)}`;
|
|
327
|
+
}
|
|
328
|
+
function renderStatusSummary(items, p) {
|
|
329
|
+
const c = statusCoverage(items);
|
|
330
|
+
const drift = c.drifted > 0 ? p.fail(` \xB7 ${c.drifted} drifted`) : "";
|
|
331
|
+
return ` ${p.strong("coverage:")} ${c.adopted}/${c.total} adopted ${p.dim("\xB7")} ${c.conformant}/${c.adopted} conformant${drift}`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// src/shared/node-version.ts
|
|
335
|
+
var MIN_NODE = "20.12.0";
|
|
336
|
+
function parts(version) {
|
|
337
|
+
const [major = 0, minor = 0, patch = 0] = version.replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
338
|
+
return [major, minor, patch];
|
|
339
|
+
}
|
|
340
|
+
function checkNodeVersion(current, min = MIN_NODE) {
|
|
341
|
+
const [cMajor, cMinor, cPatch] = parts(current);
|
|
342
|
+
const [mMajor, mMinor, mPatch] = parts(min);
|
|
343
|
+
const ok = cMajor > mMajor || cMajor === mMajor && cMinor > mMinor || cMajor === mMajor && cMinor === mMinor && cPatch >= mPatch;
|
|
344
|
+
if (ok) return { ok: true };
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
message: `sentinel requires Node >= ${min}, but you are on ${current}. Switch with fnm/nvm (e.g. \`fnm use ${mMajor}\`) and re-run.`
|
|
348
|
+
};
|
|
349
|
+
}
|
|
215
350
|
|
|
216
351
|
// bin/sentinel.ts
|
|
352
|
+
var nodeCheck = checkNodeVersion(process.versions.node);
|
|
353
|
+
if (!nodeCheck.ok) {
|
|
354
|
+
process.stderr.write(`${nodeCheck.message}
|
|
355
|
+
`);
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
registerAdapters();
|
|
217
359
|
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(
|
|
218
360
|
"before",
|
|
219
361
|
[
|
|
220
362
|
"A check composes: verb + type + location.",
|
|
221
|
-
" verb what to do: --run --inspect --report --update",
|
|
363
|
+
" verb what to do: --run --inspect --report --status --update",
|
|
222
364
|
" type which check: --lint --typescript ... (omit = all types; or --all)",
|
|
223
365
|
" where run from a MODULE dir \u2192 that module; from the ROOT \u2192 --module <name>,",
|
|
224
366
|
" --ci (affected), or all modules. --update targets one module only.",
|
|
225
367
|
""
|
|
226
368
|
].join("\n")
|
|
227
|
-
).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(
|
|
369
|
+
).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("--status", "adoption + conformity across modules (coverage + drift)").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(
|
|
228
370
|
"--module <name>",
|
|
229
371
|
"from the workspace root: scope to one module (omit = all; inside a module dir, drop this)"
|
|
230
|
-
).option("--flavour <name>", `override the detected stack preset (${FLAVOURS.join(", ")})`).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 (--update)").option("--json", "machine-readable JSON output (report/inspect/--dry-run)").
|
|
372
|
+
).option("--flavour <name>", `override the detected stack preset (${FLAVOURS.join(", ")})`).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 (--update)").option("--json", "machine-readable JSON output (report/inspect/--dry-run)").option(
|
|
373
|
+
"--max-diagnostics <n>",
|
|
374
|
+
"cap the diagnostics embedded per module in --report (0 = no cap)",
|
|
375
|
+
"100"
|
|
376
|
+
).addHelpText(
|
|
231
377
|
"after",
|
|
232
378
|
[
|
|
233
379
|
"",
|
|
@@ -238,7 +384,15 @@ program.name("sentinel").description("One CLI that guards code health: presets,
|
|
|
238
384
|
" sentinel --report --ci # from root \u2192 affected only",
|
|
239
385
|
" sentinel --update --typescript --flavour react # write stubs for the current module"
|
|
240
386
|
].join("\n")
|
|
241
|
-
).
|
|
387
|
+
).addHelpText("after", () => {
|
|
388
|
+
const available = availableTargets();
|
|
389
|
+
const planned = TARGETS.filter((t) => !available.includes(t));
|
|
390
|
+
return [
|
|
391
|
+
"",
|
|
392
|
+
`Available now: ${available.length ? available.map((t) => `--${t}`).join(", ") : "(none yet)"}`,
|
|
393
|
+
planned.length ? `Planned (ship in later tickets): ${planned.map((t) => `--${t}`).join(", ")}` : ""
|
|
394
|
+
].filter(Boolean).join("\n");
|
|
395
|
+
}).parse();
|
|
242
396
|
var opts = program.opts();
|
|
243
397
|
function toCamel(flag) {
|
|
244
398
|
return flag.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
@@ -275,6 +429,13 @@ if (opts.all && namedTargets.length > 0) {
|
|
|
275
429
|
);
|
|
276
430
|
}
|
|
277
431
|
var targets = opts.all || namedTargets.length === 0 ? [...TARGETS] : namedTargets;
|
|
432
|
+
var targetsExplicit = !(opts.all || namedTargets.length === 0);
|
|
433
|
+
var maxDiagnostics = Number(opts.maxDiagnostics);
|
|
434
|
+
if (!Number.isInteger(maxDiagnostics) || maxDiagnostics < 0) {
|
|
435
|
+
program.error(
|
|
436
|
+
`--max-diagnostics must be a non-negative integer (0 = no cap); got ${JSON.stringify(opts.maxDiagnostics)}.`
|
|
437
|
+
);
|
|
438
|
+
}
|
|
278
439
|
if (opts.dryRun && verb !== "update") {
|
|
279
440
|
program.error("--dry-run only applies to --update (the read verbs never write).");
|
|
280
441
|
}
|
|
@@ -282,6 +443,13 @@ async function runVerb() {
|
|
|
282
443
|
const { modules, scope } = resolveContext(cwd, { module: opts.module, ci: Boolean(opts.ci) });
|
|
283
444
|
const out = palette(process.stdout);
|
|
284
445
|
const err = palette(process.stderr);
|
|
446
|
+
if (opts.json && verb === "run") {
|
|
447
|
+
process.stderr.write(
|
|
448
|
+
err.warn(
|
|
449
|
+
"note: --json is ignored for --run (it streams the tool output); use --report --json for a machine envelope."
|
|
450
|
+
) + "\n"
|
|
451
|
+
);
|
|
452
|
+
}
|
|
285
453
|
const started = Date.now();
|
|
286
454
|
const { results, worstCode } = await analyse({
|
|
287
455
|
verb,
|
|
@@ -289,14 +457,20 @@ async function runVerb() {
|
|
|
289
457
|
modules,
|
|
290
458
|
runner: opts.runner,
|
|
291
459
|
flavour,
|
|
460
|
+
targetsExplicit,
|
|
461
|
+
maxDiagnostics,
|
|
292
462
|
ci: Boolean(opts.ci),
|
|
293
463
|
fix: Boolean(opts.fix),
|
|
294
464
|
onProgress: (done, total, name) => process.stderr.write(err.dim(` [${done}/${total}] ${name}
|
|
295
465
|
`))
|
|
296
466
|
});
|
|
297
|
-
const summary = generateSummaries(results);
|
|
467
|
+
const summary = generateSummaries(results, targets);
|
|
298
468
|
if (opts.json && verb !== "run") {
|
|
299
|
-
|
|
469
|
+
const payload = verb === "status" ? { ...summary, coverage: statusCoverage(summary.results) } : summary;
|
|
470
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
471
|
+
} else if (verb === "status") {
|
|
472
|
+
for (const item of summary.results) process.stdout.write(renderStatusRow(item, out) + "\n");
|
|
473
|
+
process.stdout.write(renderStatusSummary(summary.results, out) + "\n");
|
|
300
474
|
} else {
|
|
301
475
|
for (const item of summary.results) {
|
|
302
476
|
process.stdout.write(renderSummary(item, out) + "\n");
|
|
@@ -306,6 +480,15 @@ async function runVerb() {
|
|
|
306
480
|
err.dim(` ${modules.length} module(s) [${scope}] in ${Date.now() - started}ms
|
|
307
481
|
`)
|
|
308
482
|
);
|
|
483
|
+
if (targetsExplicit && summary.skipped.length > 0) {
|
|
484
|
+
const avail = availableTargets();
|
|
485
|
+
process.stderr.write(
|
|
486
|
+
err.fail(
|
|
487
|
+
`sentinel: target(s) not available yet: ${summary.skipped.map((t) => `--${t}`).join(", ")}. Available now: ${avail.length ? avail.map((t) => `--${t}`).join(", ") : "(none yet)"}.`
|
|
488
|
+
) + "\n"
|
|
489
|
+
);
|
|
490
|
+
return 1;
|
|
491
|
+
}
|
|
309
492
|
return verb === "run" || opts.ci ? worstCode : 0;
|
|
310
493
|
}
|
|
311
494
|
async function runUpdate() {
|
|
@@ -316,18 +499,25 @@ async function runUpdate() {
|
|
|
316
499
|
"--update writes files: target one module (run from its directory, or pass --module). Adopting every module at once is intentionally not allowed \u2014 adopt gradually."
|
|
317
500
|
);
|
|
318
501
|
}
|
|
502
|
+
const available = availableTargets();
|
|
503
|
+
if (targetsExplicit) {
|
|
504
|
+
const unwired = targets.filter((type) => !available.includes(type));
|
|
505
|
+
if (unwired.length > 0) {
|
|
506
|
+
program.error(
|
|
507
|
+
`target(s) not available yet: ${unwired.map((t) => `--${t}`).join(", ")}. Available now: ${available.map((t) => `--${t}`).join(", ") || "(none yet)"}.`
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const toRun = targets.filter((type) => available.includes(type));
|
|
319
512
|
let worst = 0;
|
|
320
|
-
for (const type of
|
|
513
|
+
for (const type of toRun) {
|
|
321
514
|
try {
|
|
322
515
|
const code = await dispatch({
|
|
323
516
|
verb,
|
|
324
517
|
target: type,
|
|
325
518
|
runner: opts.runner,
|
|
326
|
-
module: module.name,
|
|
327
519
|
cwd: module.root,
|
|
328
520
|
flavour,
|
|
329
|
-
ci: Boolean(opts.ci),
|
|
330
|
-
fix: Boolean(opts.fix),
|
|
331
521
|
dryRun: Boolean(opts.dryRun),
|
|
332
522
|
json: Boolean(opts.json)
|
|
333
523
|
});
|
|
@@ -336,13 +526,11 @@ async function runUpdate() {
|
|
|
336
526
|
process.stderr.write(`
|
|
337
527
|
sentinel (${type}): ${asMessage(err)}
|
|
338
528
|
`);
|
|
339
|
-
if (targets.length === 1) return 1;
|
|
340
529
|
worst = Math.max(worst, 1);
|
|
341
530
|
}
|
|
342
531
|
}
|
|
343
532
|
return worst;
|
|
344
533
|
}
|
|
345
|
-
registerAdapters();
|
|
346
534
|
(verb === "update" ? runUpdate() : runVerb()).then((code) => process.exit(code)).catch((err) => {
|
|
347
535
|
process.stderr.write(`
|
|
348
536
|
sentinel: ${asMessage(err)}
|