@hublo/sentinel 0.1.0-alpha.8 → 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 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:
@@ -260,6 +274,7 @@ OPTIONS --module <name> from the root: scope to one module
260
274
  --ci from the root: affected only; non-zero exit on failure
261
275
  --fix auto-fix where applicable
262
276
  --dry-run preview a --update without writing
277
+ --json machine-readable output (report / inspect / status / --dry-run)
263
278
 
264
279
  EXAMPLES sentinel --run --typescript # in a module → that module
265
280
  sentinel --report --typescript --module bff-admin # from root → one module
@@ -309,8 +324,7 @@ src/
309
324
  runners/ # one runner per sub-tool (duplication, complexity, ...)
310
325
  shared/ # reusable utils (package-json, deep-merge, text)
311
326
  tests/ # unit tests + tests/e2e (runs the built dist binary)
312
- .changeset/ # release notes
313
- .github/workflows/ # ci.yml (PR checks) + release.yml (changesets publish)
327
+ .github/workflows/ # ci.yml (PR checks) + publish.yml (manual, version-input publish)
314
328
  ```
315
329
 
316
330
  Subpath exports (in `package.json`) expose presets to consumers. Shipped today:
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- detectFramework,
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-UG74KTIU.js";
13
+ } from "../chunk-NQ7GNHFT.js";
13
14
 
14
15
  // bin/sentinel.ts
15
16
  import { program } from "commander";
@@ -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
- const flavour2 = params.flavour ?? detectFramework(readProjectPackageJson(module.root));
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({ project: module.name, target, flavour: flavour2, ok: result.ok, data: {} });
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,21 +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
200
  } else if (params.verb === "inspect") {
166
201
  const config = await adapter.inspect(ctx);
167
- results.push({ project: module.name, target, flavour: flavour2, ok: true, data: config });
202
+ results.push({
203
+ project: module.name,
204
+ target,
205
+ flavour: flavour2,
206
+ ok: true,
207
+ status: "ok",
208
+ data: config
209
+ });
168
210
  } else {
169
211
  const status = await adapter.status(ctx);
170
212
  const ok = !status.adopted || status.conformant;
171
- results.push({ project: module.name, target, flavour: flavour2, ok, data: status });
213
+ results.push({
214
+ project: module.name,
215
+ target,
216
+ flavour: flavour2,
217
+ ok,
218
+ status: ok ? "ok" : "failed",
219
+ data: status
220
+ });
172
221
  if (!ok) worstCode = Math.max(worstCode, 1);
173
222
  }
174
223
  } catch (error) {
175
224
  const message = error instanceof Error ? error.message : String(error);
176
- results.push({ project: module.name, target, flavour: flavour2, ok: false, data: { error: message } });
225
+ results.push({
226
+ project: module.name,
227
+ target,
228
+ flavour: flavour2,
229
+ ok: false,
230
+ status: "failed",
231
+ data: { error: message }
232
+ });
177
233
  worstCode = Math.max(worstCode, 1);
178
234
  }
179
235
  }
@@ -182,16 +238,34 @@ async function analyse(params) {
182
238
  return { results, worstCode };
183
239
  }
184
240
  function generateSummary(result) {
185
- const { project, target, flavour: flavour2, ok, data } = result;
241
+ const { project, target, flavour: flavour2, ok, status, data } = result;
186
242
  const details = data && typeof data === "object" ? data : { value: data };
187
- return { project, target, flavour: flavour2, ok, ...details };
243
+ return { project, target, flavour: flavour2, ok, status, ...details };
188
244
  }
189
- function generateSummaries(results) {
190
- return { schemaVersion: 1, results: results.map(generateSummary) };
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
+ };
191
253
  }
192
254
 
193
255
  // src/core/render.ts
194
- var HEAD_KEYS = /* @__PURE__ */ new Set(["project", "module", "target", "flavour", "ok"]);
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
+ ]);
195
269
  function isDeferredRules(value) {
196
270
  return Array.isArray(value) && value.every((entry) => typeof entry === "object" && entry !== null && "rule" in entry);
197
271
  }
@@ -199,9 +273,16 @@ function renderValue(key, value, p) {
199
273
  if ((key === "errors" || key === "implicitAny") && typeof value === "number") {
200
274
  return value > 0 ? p.fail(String(value)) : p.ok(String(value));
201
275
  }
276
+ if (key === "implicitAny" && value === "deferred") return p.warn("deferred");
202
277
  return typeof value === "string" ? value : JSON.stringify(value);
203
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
+ }
204
284
  function renderSummary(item, p) {
285
+ if (item.status === "unsupported") return renderUnsupportedRow(item, p);
205
286
  const ok = item.ok === true;
206
287
  const mark = ok ? p.ok("\u2713") : p.fail("\u2717");
207
288
  const head = ` ${mark} ${p.strong(String(item.project))} ${p.dim(`(${item.flavour})`)} ${item.target}`;
@@ -218,10 +299,11 @@ function renderSummary(item, p) {
218
299
  return lines.join("\n");
219
300
  }
220
301
  function statusCoverage(items) {
221
- const adopted = items.filter((i) => i.adopted === true);
302
+ const relevant = items.filter((i) => i.status !== "unsupported");
303
+ const adopted = relevant.filter((i) => i.adopted === true);
222
304
  const conformant = adopted.filter((i) => i.conformant === true);
223
305
  return {
224
- total: items.length,
306
+ total: relevant.length,
225
307
  adopted: adopted.length,
226
308
  conformant: conformant.length,
227
309
  drifted: adopted.length - conformant.length
@@ -231,6 +313,7 @@ function presetShort(preset) {
231
313
  return typeof preset === "string" ? preset.replace("@hublo/sentinel/tsconfig/", "") : "?";
232
314
  }
233
315
  function renderStatusRow(item, p) {
316
+ if (item.status === "unsupported") return renderUnsupportedRow(item, p);
234
317
  const adopted = item.adopted === true;
235
318
  const conformant = item.conformant === true;
236
319
  const mark = !adopted ? p.dim("\xB7") : conformant ? p.ok("\u2713") : p.fail("\u2717");
@@ -248,7 +331,31 @@ function renderStatusSummary(items, p) {
248
331
  return ` ${p.strong("coverage:")} ${c.adopted}/${c.total} adopted ${p.dim("\xB7")} ${c.conformant}/${c.adopted} conformant${drift}`;
249
332
  }
250
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
+ }
350
+
251
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();
252
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(
253
360
  "before",
254
361
  [
@@ -262,7 +369,11 @@ program.name("sentinel").description("One CLI that guards code health: presets,
262
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(
263
370
  "--module <name>",
264
371
  "from the workspace root: scope to one module (omit = all; inside a module dir, drop this)"
265
- ).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)").addHelpText(
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(
266
377
  "after",
267
378
  [
268
379
  "",
@@ -273,7 +384,15 @@ program.name("sentinel").description("One CLI that guards code health: presets,
273
384
  " sentinel --report --ci # from root \u2192 affected only",
274
385
  " sentinel --update --typescript --flavour react # write stubs for the current module"
275
386
  ].join("\n")
276
- ).parse();
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();
277
396
  var opts = program.opts();
278
397
  function toCamel(flag) {
279
398
  return flag.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
@@ -310,6 +429,13 @@ if (opts.all && namedTargets.length > 0) {
310
429
  );
311
430
  }
312
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
+ }
313
439
  if (opts.dryRun && verb !== "update") {
314
440
  program.error("--dry-run only applies to --update (the read verbs never write).");
315
441
  }
@@ -317,6 +443,13 @@ async function runVerb() {
317
443
  const { modules, scope } = resolveContext(cwd, { module: opts.module, ci: Boolean(opts.ci) });
318
444
  const out = palette(process.stdout);
319
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
+ }
320
453
  const started = Date.now();
321
454
  const { results, worstCode } = await analyse({
322
455
  verb,
@@ -324,12 +457,14 @@ async function runVerb() {
324
457
  modules,
325
458
  runner: opts.runner,
326
459
  flavour,
460
+ targetsExplicit,
461
+ maxDiagnostics,
327
462
  ci: Boolean(opts.ci),
328
463
  fix: Boolean(opts.fix),
329
464
  onProgress: (done, total, name) => process.stderr.write(err.dim(` [${done}/${total}] ${name}
330
465
  `))
331
466
  });
332
- const summary = generateSummaries(results);
467
+ const summary = generateSummaries(results, targets);
333
468
  if (opts.json && verb !== "run") {
334
469
  const payload = verb === "status" ? { ...summary, coverage: statusCoverage(summary.results) } : summary;
335
470
  process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
@@ -345,6 +480,15 @@ async function runVerb() {
345
480
  err.dim(` ${modules.length} module(s) [${scope}] in ${Date.now() - started}ms
346
481
  `)
347
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
+ }
348
492
  return verb === "run" || opts.ci ? worstCode : 0;
349
493
  }
350
494
  async function runUpdate() {
@@ -355,18 +499,25 @@ async function runUpdate() {
355
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."
356
500
  );
357
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));
358
512
  let worst = 0;
359
- for (const type of targets) {
513
+ for (const type of toRun) {
360
514
  try {
361
515
  const code = await dispatch({
362
516
  verb,
363
517
  target: type,
364
518
  runner: opts.runner,
365
- module: module.name,
366
519
  cwd: module.root,
367
520
  flavour,
368
- ci: Boolean(opts.ci),
369
- fix: Boolean(opts.fix),
370
521
  dryRun: Boolean(opts.dryRun),
371
522
  json: Boolean(opts.json)
372
523
  });
@@ -375,13 +526,11 @@ async function runUpdate() {
375
526
  process.stderr.write(`
376
527
  sentinel (${type}): ${asMessage(err)}
377
528
  `);
378
- if (targets.length === 1) return 1;
379
529
  worst = Math.max(worst, 1);
380
530
  }
381
531
  }
382
532
  return worst;
383
533
  }
384
- registerAdapters();
385
534
  (verb === "update" ? runUpdate() : runVerb()).then((code) => process.exit(code)).catch((err) => {
386
535
  process.stderr.write(`
387
536
  sentinel: ${asMessage(err)}