@hublo/sentinel 1.1.0-alpha.4 → 1.1.0-alpha.5

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,17 +43,20 @@ 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:** the **TypeScript** and **lint** tools. The `--test` / `--build` snippets
47
- > below illustrate the end state and land with their own ticket.
46
+ > **Shipped today:** the **TypeScript**, **lint** and **format** tools. The `--test` / `--build`
47
+ > snippets below illustrate the end state and land with their own ticket.
48
48
 
49
49
  **Step 1 — put a module on sentinel** (once per module, by a dev; the files are committed). Run from the app dir; `--init` does it all, nothing is hand-edited:
50
50
 
51
51
  ```bash
52
- sentinel --init --typescript --preset <react|nest|node|svelte>
53
- sentinel --init --lint --preset <react|nest|node|svelte>
52
+ sentinel --init --preset <react|nest|node|svelte> # every role sentinel ships
54
53
  pnpm install # fetch what --init declared, then commit
55
54
  ```
56
55
 
56
+ With no target named, `--init` adopts every role, in the order that makes the result correct:
57
+ the linter's autofix runs, then the formatter runs **last** and formats everything every role
58
+ wrote. Naming one target (`--init --lint`) adopts just that role and leaves the rest alone.
59
+
57
60
  `--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.
58
61
 
59
62
  Those files are tiny, they just point at a sentinel preset. What gets committed:
@@ -61,7 +64,26 @@ Those files are tiny, they just point at a sentinel preset. What gets committed:
61
64
  ```jsonc
62
65
  // .oxlintrc.json — generated. Adoption REPLACES eslint rather than sitting beside it, so
63
66
  // the module's eslint config is DELETED and this is the only linter config it keeps.
64
- { "extends": ["./node_modules/@hublo/sentinel/oxlint/react.json"] }
67
+ // The RULES live in the preset, so a preset change arrives by reinstalling. `ignorePatterns`
68
+ // cannot: it does not cross an `extends` boundary, so it is written here, which is what
69
+ // makes the file correct for your editor and for `oxlint -c` on its own.
70
+ {
71
+ "extends": ["./node_modules/@hublo/sentinel/oxlint/react.json"],
72
+ "ignorePatterns": ["**/dist/**", "**/node_modules/**", "..."],
73
+ }
74
+ ```
75
+
76
+ ```jsonc
77
+ // .oxfmtrc.json — generated, and the one config that is NOT a stub: oxfmt has no `extends`
78
+ // and ignores the key silently, so the preset's values are written in. Anything the module
79
+ // deliberately formats differently is declared in `$sentinel.local` and survives a re-init.
80
+ {
81
+ "$sentinel": { "preset": "base", "version": "1.1.0", "local": [] },
82
+ "printWidth": 80,
83
+ "semi": false,
84
+ "singleQuote": true,
85
+ "sortImports": { "groups": ["builtin", "external", "internal", ["parent", "index"], "sibling"] },
86
+ }
65
87
  ```
66
88
 
67
89
  ```jsonc
@@ -70,7 +92,7 @@ Those files are tiny, they just point at a sentinel preset. What gets committed:
70
92
  {
71
93
  "extends": ["../../tsconfig.base.json", "@hublo/sentinel/tsconfig/react"],
72
94
  "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } },
73
- "include": ["src"]
95
+ "include": ["src"],
74
96
  }
75
97
  ```
76
98
 
@@ -82,11 +104,15 @@ And the app's `package.json` scripts route every check through the one CLI (run
82
104
  // package.json
83
105
  {
84
106
  "scripts": {
85
- "lint": "sentinel --run --lint",
86
- "lint:fix": "sentinel --run --lint --fix",
107
+ // `lint` checks BOTH, the way `prettier --check . && eslint .` used to. In the :fix
108
+ // pair the formatter runs last: the linter's autofix rewrites code.
109
+ "lint": "sentinel --run --lint && sentinel --run --format",
110
+ "lint:fix": "sentinel --run --lint --fix && sentinel --run --format --fix",
111
+ "format": "sentinel --run --format",
112
+ "format:fix": "sentinel --run --format --fix",
87
113
  "typecheck": "sentinel --run --typescript",
88
- "test": "sentinel --run --test"
89
- }
114
+ "test": "sentinel --run --test",
115
+ },
90
116
  }
91
117
  ```
92
118
 
@@ -121,6 +147,10 @@ pnpm dlx @hublo/sentinel@<exact-version> --inspect --typescript --module <name>
121
147
 
122
148
  ## Docs & cheat sheets
123
149
 
150
+ - [`docs/performance.md`](docs/performance.md) — the measured baseline (`pnpm bench`), so a regression is something you can see rather than argue about, and what is deliberately left unoptimised.
151
+ - [`docs/using-sentinel.md`](docs/using-sentinel.md) — every verb for every role, scoping a run to some files, what a fully adopted module looks like, and how versions move between modules.
152
+ - [`docs/lint-adoption.md`](docs/lint-adoption.md) — migrating a module from ESLint to Oxlint: the two steps, what `--init` writes and removes, the two react tiers, and the failures worth recognising.
153
+ - [`docs/format-adoption.md`](docs/format-adoption.md) — migrating a module from Prettier to oxfmt: why this config is materialized rather than a stub, how a module keeps its own formatting, and what did not survive the move.
124
154
  - [`docs/typescript-adoption.md`](docs/typescript-adoption.md) — the adoption cheat sheet: the two adoption steps, the command model (verb x type x location), options, reading a report, and troubleshooting.
125
155
  - [`docs/typescript-traces.md`](docs/typescript-traces.md) — a **generated, versioned** reference of live command + output traces (every verb, option, config result and edge case) against the mock monorepo. Regenerate after CLI changes with `pnpm docs:traces`.
126
156
 
@@ -471,7 +501,7 @@ Yes. Per app (app A defaults to eslint, app B to biome), or even in the same app
471
501
  Most tools expose `extends` or a plugin mechanism to compose config, so the stub just points at the sentinel preset. For the rare tool that doesn't, sentinel exposes the config **directly**, it generates the full config from its preset (still one source, drift-checked).
472
502
 
473
503
  **What does an nx `project.json` look like?**
474
- nx is a **task runner**: it just runs the target's script. So `project.json` barely changes, the lint / test / typecheck targets run the `package.json` scripts (which call sentinel), or stay nx-inferred. nx keeps the graph, affected set, and cache; sentinel provides the config + execution.
504
+ nx is a **task runner**: it just runs the target's script, so the command belongs in `package.json` and nx infers a target from it (verified: it does this even when the module also has a `project.json`). Adoption therefore REMOVES the role's target from `project.json`, leaving every other one alone, and writes only what nx alone needs (cache inputs) into the `nx` block of `package.json`. Each role a module adopts decouples one more thing from nx and shrinks that file. nx keeps the graph, affected set, and cache; sentinel provides the config + execution.
475
505
 
476
506
  ## Roadmap
477
507
 
@@ -3,18 +3,20 @@ import {
3
3
  PRESET_NAMES,
4
4
  TARGETS,
5
5
  VERBS,
6
+ WORKSPACE_ROOT_MARKER,
6
7
  availableTargets,
7
8
  describeFramework,
8
9
  dispatch,
10
+ ensureWorkspacePrep,
11
+ findWorkspaceRoot,
9
12
  palette,
10
13
  readNxProjectName,
11
- readOwnPackage,
12
14
  readOwnVersion,
13
15
  readProjectPackageJson,
14
16
  registerAdapters,
15
17
  resolve,
16
18
  resolveBin
17
- } from "../chunk-FLLX5MN3.js";
19
+ } from "../chunk-NAUACDXT.js";
18
20
 
19
21
  // bin/sentinel.ts
20
22
  import { program } from "commander";
@@ -73,9 +75,6 @@ function discoverModules(cwd2, options = {}) {
73
75
  return modules.filter((module) => affected.has(module.name));
74
76
  }
75
77
 
76
- // src/core/settings.ts
77
- var WORKSPACE_ROOT_MARKER = "nx.json";
78
-
79
78
  // src/core/context.ts
80
79
  var MODULE_MARKERS = ["package.json", "project.json"];
81
80
  function isModuleDir(cwd2) {
@@ -109,6 +108,71 @@ function resolveContext(cwd2, opts2) {
109
108
  );
110
109
  }
111
110
 
111
+ // src/cli/run-init.ts
112
+ function asMessage(error) {
113
+ return error instanceof Error ? error.message : String(error);
114
+ }
115
+ async function runInit(ctx) {
116
+ const { modules, scope } = resolveContext(ctx.cwd, { module: ctx.module, ci: false });
117
+ const [module] = modules;
118
+ if (scope === "all" || scope === "affected" || !module) {
119
+ ctx.fail(
120
+ "--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."
121
+ );
122
+ }
123
+ const available = availableTargets();
124
+ if (ctx.targetsExplicit) {
125
+ const unwired = ctx.targets.filter((type) => !available.includes(type));
126
+ if (unwired.length > 0) {
127
+ ctx.fail(
128
+ `target(s) not available yet: ${unwired.map((t) => `--${t}`).join(", ")}. Available now: ${available.map((t) => `--${t}`).join(", ") || "(none yet)"}.`
129
+ );
130
+ }
131
+ }
132
+ const toRun = ctx.targets.filter((type) => available.includes(type)).sort((a, b) => Number(a === "format") - Number(b === "format"));
133
+ let worst = 0;
134
+ for (const type of toRun) {
135
+ try {
136
+ const code = await dispatch({
137
+ verb: ctx.verb,
138
+ target: type,
139
+ runner: ctx.runner,
140
+ cwd: module.root,
141
+ preset: ctx.preset,
142
+ dryRun: ctx.dryRun,
143
+ json: ctx.json
144
+ });
145
+ worst = Math.max(worst, code);
146
+ } catch (error) {
147
+ process.stderr.write(`
148
+ sentinel (${type}): ${asMessage(error)}
149
+ `);
150
+ worst = Math.max(worst, 1);
151
+ }
152
+ }
153
+ if (worst === 0) {
154
+ const root = findWorkspaceRoot(module.root);
155
+ if (root) {
156
+ const changes = ensureWorkspacePrep({
157
+ root,
158
+ dryRun: ctx.dryRun,
159
+ // Only when the FORMAT role actually adopted: a module still formatted by the root
160
+ // must not be excluded from it.
161
+ formattedModule: toRun.includes("format") ? module.root : void 0
162
+ });
163
+ const prefix = ctx.dryRun ? " dry run: " : " ";
164
+ for (const change of changes) process.stderr.write(`${prefix}${change}
165
+ `);
166
+ if (changes.length > 0 && !ctx.dryRun) {
167
+ process.stderr.write(
168
+ palette(process.stderr).dim(" run `pnpm install` to apply the workspace changes\n")
169
+ );
170
+ }
171
+ }
172
+ }
173
+ return worst;
174
+ }
175
+
112
176
  // src/core/orchestrate.ts
113
177
  async function analyse(params) {
114
178
  const results = [];
@@ -191,7 +255,7 @@ async function analyse(params) {
191
255
  worstCode = Math.max(worstCode, result.code);
192
256
  } else if (params.verb === "inspect") {
193
257
  const [config, adoption] = await Promise.all([adapter.inspect(ctx), adapter.status(ctx)]);
194
- const ok = !adoption.adopted || adoption.conformant;
258
+ const ok = !adoption.unreadable && (!adoption.adopted || adoption.conformant);
195
259
  results.push({
196
260
  project: module.name,
197
261
  target,
@@ -206,7 +270,7 @@ async function analyse(params) {
206
270
  if (!ok) worstCode = Math.max(worstCode, 1);
207
271
  } else {
208
272
  const status = await adapter.status(ctx);
209
- const ok = !status.adopted || status.conformant;
273
+ const ok = !status.unreadable && (!status.adopted || status.conformant);
210
274
  results.push({
211
275
  project: module.name,
212
276
  target,
@@ -269,14 +333,26 @@ function isRuleList(value) {
269
333
  var RULE_BLOCKS = [
270
334
  { key: "deferred", label: "deferred (phase 1, non-breaking):" },
271
335
  { key: "disabled", label: "not enforced, by decision:" },
272
- { key: "downgraded", label: "reported, but not blocking:" }
336
+ { key: "downgraded", label: "reported, but not blocking:" },
337
+ // The format role's equivalent. A rule parked and an option overridden are the same kind of
338
+ // fact: this module deliberately does not do what the standard says, and someone should be
339
+ // able to see every such decision in one place, with the reason beside it.
340
+ { key: "overrides", label: "formatted differently from the repo standard:" }
273
341
  ];
342
+ var MAX_INLINE_WIDTH = 60;
274
343
  function renderValue(key, value, p) {
275
344
  if ((key === "errors" || key === "implicitAny") && typeof value === "number") {
276
345
  return value > 0 ? p.fail(String(value)) : p.ok(String(value));
277
346
  }
278
347
  if (key === "implicitAny" && value === "deferred") return p.warn("deferred");
279
- return typeof value === "string" ? value : JSON.stringify(value);
348
+ if (typeof value === "string") return value;
349
+ const json = JSON.stringify(value);
350
+ if (json !== void 0 && json.length > MAX_INLINE_WIDTH && typeof value === "object" && value) {
351
+ const count = Array.isArray(value) ? value.length : Object.keys(value).length;
352
+ const noun = Array.isArray(value) ? "entries" : "keys";
353
+ return p.dim(`{${count} ${noun}, see --json}`);
354
+ }
355
+ return json;
280
356
  }
281
357
  function renderUnsupportedRow(item, p) {
282
358
  const reason = typeof item.reason === "string" ? ` ${p.dim(`(${item.reason})`)}` : "";
@@ -345,71 +421,76 @@ function renderStatusSummary(items, p) {
345
421
  return ` ${p.strong("coverage:")} ${c.adopted}/${c.total} adopted ${p.dim("\xB7")} ${c.conformant}/${c.adopted} conformant${drift}`;
346
422
  }
347
423
 
348
- // src/core/workspace-prep.ts
349
- import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
350
- import { dirname, join as join3 } from "path";
351
- var OVERRIDE_KEY = "i18next>typescript";
352
- var NATIVE_TS_ALIAS = "@typescript/native";
353
- var WORKSPACE_YAML = "pnpm-workspace.yaml";
354
- var RELEASE_AGE_KEY = "minimumReleaseAgeExclude";
355
- var LIST_ITEM = /^(\s*)-\s+(.*?)\s*$/;
356
- function findWorkspaceRoot(startDir) {
357
- let dir = startDir;
358
- for (; ; ) {
359
- if (existsSync2(join3(dir, WORKSPACE_ROOT_MARKER))) return dir;
360
- const parent = dirname(dir);
361
- if (parent === dir) return void 0;
362
- dir = parent;
424
+ // src/cli/run-verb.ts
425
+ async function runVerb(ctx) {
426
+ const { modules, scope } = resolveContext(ctx.cwd, { module: ctx.module, ci: ctx.ci });
427
+ const out = palette(process.stdout);
428
+ const err = palette(process.stderr);
429
+ const shaped = ctx.verb === "run" && ctx.json ? "report" : ctx.verb;
430
+ const replacement = {
431
+ report: "--run --json",
432
+ status: "--inspect"
433
+ };
434
+ const instead = replacement[ctx.verb];
435
+ if (instead) {
436
+ process.stderr.write(
437
+ err.warn(`sentinel: --${ctx.verb} is deprecated, use ${instead}`) + " (it still works)\n"
438
+ );
363
439
  }
364
- }
365
- function declaredNativeTs(pkg) {
366
- const spec = pkg.dependencies?.[NATIVE_TS_ALIAS] ?? pkg.devDependencies?.[NATIVE_TS_ALIAS];
367
- if (!spec) return void 0;
368
- const version = spec.slice(spec.lastIndexOf("@") + 1).replace(/^[\^~>=<\s]+/, "");
369
- return version || void 0;
370
- }
371
- function ensureI18nextSingleton(root, dryRun) {
372
- const pkgPath = join3(root, "package.json");
373
- if (!existsSync2(pkgPath)) return void 0;
374
- const pkg = JSON.parse(readFileSync2(pkgPath, "utf8"));
375
- const want = declaredNativeTs(pkg);
376
- if (!want) return void 0;
377
- const have = pkg.pnpm?.overrides?.[OVERRIDE_KEY];
378
- if (have === want) return void 0;
379
- if (dryRun) return `would pin ${OVERRIDE_KEY} to ${want} (i18next singleton)`;
380
- pkg.pnpm ??= {};
381
- pkg.pnpm.overrides ??= {};
382
- pkg.pnpm.overrides[OVERRIDE_KEY] = want;
383
- writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
384
- return `pinned ${OVERRIDE_KEY} to ${want} in package.json (i18next singleton)`;
385
- }
386
- function ensureReleaseAgeAllowList(root, dryRun) {
387
- const yamlPath = join3(root, WORKSPACE_YAML);
388
- if (!existsSync2(yamlPath)) return void 0;
389
- const own = readOwnPackage().name;
390
- const lines = readFileSync2(yamlPath, "utf8").split("\n");
391
- const keyIdx = lines.findIndex((line) => line.replace(/\s+$/, "") === `${RELEASE_AGE_KEY}:`);
392
- if (keyIdx === -1) return void 0;
393
- let lastItemIdx = keyIdx;
394
- let indent = " ";
395
- for (let i = keyIdx + 1; i < lines.length; i++) {
396
- const match = lines[i]?.match(LIST_ITEM);
397
- if (!match) break;
398
- indent = match[1] ?? indent;
399
- lastItemIdx = i;
400
- if ((match[2] ?? "").replace(/^['"]|['"]$/g, "") === own) return void 0;
440
+ if (ctx.toolArgs.length > 0) {
441
+ process.stderr.write(
442
+ err.warn(
443
+ `sentinel: NOT the standard check \u2014 passing ${ctx.toolArgs.join(" ")} to the ${ctx.targets[0]} tool.`
444
+ ) + "\n"
445
+ );
401
446
  }
402
- if (dryRun) return `would allow-list ${own} under ${RELEASE_AGE_KEY}`;
403
- lines.splice(lastItemIdx + 1, 0, `${indent}- '${own}'`);
404
- writeFileSync(yamlPath, lines.join("\n"));
405
- return `allow-listed ${own} under ${RELEASE_AGE_KEY} in ${WORKSPACE_YAML}`;
406
- }
407
- function ensureWorkspacePrep(opts2) {
408
- const dryRun = Boolean(opts2.dryRun);
409
- return [
410
- ensureI18nextSingleton(opts2.root, dryRun),
411
- ensureReleaseAgeAllowList(opts2.root, dryRun)
412
- ].filter((message) => message !== void 0);
447
+ const started = Date.now();
448
+ const { results, worstCode } = await analyse({
449
+ verb: shaped,
450
+ targets: ctx.targets,
451
+ modules,
452
+ runner: ctx.runner,
453
+ preset: ctx.preset,
454
+ targetsExplicit: ctx.targetsExplicit,
455
+ maxDiagnostics: ctx.maxDiagnostics,
456
+ ci: ctx.ci,
457
+ fix: ctx.fix,
458
+ toolArgs: ctx.toolArgs,
459
+ // Per-module progress is a HUMAN affordance: it tells someone staring at a slow sweep
460
+ // that it is alive. On this monorepo's 441 projects it is 441 lines that bury the four
461
+ // that matter, and it is pure noise in a captured log. So emit it only for an
462
+ // interactive terminal; a pipe, CI, or a generated trace gets the results and the
463
+ // one-line summary, which is all any of them can use.
464
+ onProgress: process.stderr.isTTY ? (done, total, name) => process.stderr.write(err.dim(` [${done}/${total}] ${name}
465
+ `)) : void 0
466
+ });
467
+ const summary = generateSummaries(results, ctx.targets);
468
+ if (ctx.json) {
469
+ const base = ctx.verb === "status" || ctx.verb === "inspect" ? { ...summary, coverage: statusCoverage(summary.results) } : summary;
470
+ const payload = ctx.toolArgs.length > 0 ? { ...base, toolArgs: ctx.toolArgs } : base;
471
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
472
+ } else if (ctx.verb === "status" || ctx.verb === "inspect" && modules.length > 1) {
473
+ for (const item of summary.results) process.stdout.write(renderStatusRow(item, out) + "\n");
474
+ process.stdout.write(renderStatusSummary(summary.results, out) + "\n");
475
+ } else {
476
+ for (const item of summary.results) {
477
+ process.stdout.write(renderSummary(item, out) + "\n");
478
+ }
479
+ }
480
+ process.stderr.write(
481
+ err.dim(` ${modules.length} module(s) [${scope}] in ${Date.now() - started}ms
482
+ `)
483
+ );
484
+ if (ctx.targetsExplicit && summary.skipped.length > 0) {
485
+ const avail = availableTargets();
486
+ process.stderr.write(
487
+ err.fail(
488
+ `sentinel: target(s) not available yet: ${summary.skipped.map((t) => `--${t}`).join(", ")}. Available now: ${avail.length ? avail.map((t) => `--${t}`).join(", ") : "(none yet)"}.`
489
+ ) + "\n"
490
+ );
491
+ return 1;
492
+ }
493
+ return ctx.verb === "run" || ctx.ci ? worstCode : 0;
413
494
  }
414
495
 
415
496
  // src/shared/node-version.ts
@@ -459,7 +540,7 @@ program.name("sentinel").description("One CLI that guards code health: presets,
459
540
  "100"
460
541
  ).addHelpText(
461
542
  "after",
462
- "\n -- <tool options> --run only, one type: pass the rest to the tool itself\n e.g. -- --noImplicitAny (tsc), -- --deny-warnings (oxlint)"
543
+ "\n -- <options|files> --run only, one type: pass the rest to the tool itself\n e.g. -- --noImplicitAny (tsc), -- --deny-warnings (oxlint).\n A trailing FILE or directory scopes the run to it, which is\n what a commit hook wants: -- src/a.ts src/b.ts"
463
544
  ).addHelpText(
464
545
  "after",
465
546
  [
@@ -470,6 +551,7 @@ program.name("sentinel").description("One CLI that guards code health: presets,
470
551
  " sentinel --run --json # from root \u2192 all types, machine output",
471
552
  " sentinel --run --ci # from root \u2192 affected only",
472
553
  " sentinel --inspect --typescript # from root \u2192 adoption + what is deferred",
554
+ " sentinel --init --preset react # adopt EVERY role: lint, format, typescript",
473
555
  " sentinel --init --typescript --preset react # set up the current module + workspace",
474
556
  " sentinel --run --typescript -- --noImplicitAny # ask the tool a question of your own"
475
557
  ].join("\n")
@@ -505,7 +587,7 @@ function parseFlavour(value) {
505
587
  }
506
588
  return value;
507
589
  }
508
- function asMessage(err) {
590
+ function asMessage2(err) {
509
591
  return err instanceof Error ? err.message : String(err);
510
592
  }
511
593
  var verb = pickOne("verb", VERBS);
@@ -549,130 +631,6 @@ if (toolArgs.length > 0) {
549
631
  );
550
632
  }
551
633
  }
552
- async function runVerb() {
553
- const { modules, scope } = resolveContext(cwd, { module: opts.module, ci: Boolean(opts.ci) });
554
- const out = palette(process.stdout);
555
- const err = palette(process.stderr);
556
- const shaped = verb === "run" && opts.json ? "report" : verb;
557
- const replacement = {
558
- report: "--run --json",
559
- status: "--inspect"
560
- };
561
- const instead = replacement[verb];
562
- if (instead) {
563
- process.stderr.write(
564
- err.warn(`sentinel: --${verb} is deprecated, use ${instead}`) + " (it still works)\n"
565
- );
566
- }
567
- if (toolArgs.length > 0) {
568
- process.stderr.write(
569
- err.warn(
570
- `sentinel: NOT the standard check \u2014 passing ${toolArgs.join(" ")} to the ${targets[0]} tool.`
571
- ) + "\n"
572
- );
573
- }
574
- const started = Date.now();
575
- const { results, worstCode } = await analyse({
576
- verb: shaped,
577
- targets,
578
- modules,
579
- runner: opts.runner,
580
- preset,
581
- targetsExplicit,
582
- maxDiagnostics,
583
- ci: Boolean(opts.ci),
584
- fix: Boolean(opts.fix),
585
- toolArgs,
586
- // Per-module progress is a HUMAN affordance: it tells someone staring at a slow sweep
587
- // that it is alive. On this monorepo's 441 projects it is 441 lines that bury the four
588
- // that matter, and it is pure noise in a captured log. So emit it only for an
589
- // interactive terminal; a pipe, CI, or a generated trace gets the results and the
590
- // one-line summary, which is all any of them can use.
591
- onProgress: process.stderr.isTTY ? (done, total, name) => process.stderr.write(err.dim(` [${done}/${total}] ${name}
592
- `)) : void 0
593
- });
594
- const summary = generateSummaries(results, targets);
595
- if (opts.json) {
596
- const base = verb === "status" || verb === "inspect" ? { ...summary, coverage: statusCoverage(summary.results) } : summary;
597
- const payload = toolArgs.length > 0 ? { ...base, toolArgs } : base;
598
- process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
599
- } else if (verb === "status" || verb === "inspect" && modules.length > 1) {
600
- for (const item of summary.results) process.stdout.write(renderStatusRow(item, out) + "\n");
601
- process.stdout.write(renderStatusSummary(summary.results, out) + "\n");
602
- } else {
603
- for (const item of summary.results) {
604
- process.stdout.write(renderSummary(item, out) + "\n");
605
- }
606
- }
607
- process.stderr.write(
608
- err.dim(` ${modules.length} module(s) [${scope}] in ${Date.now() - started}ms
609
- `)
610
- );
611
- if (targetsExplicit && summary.skipped.length > 0) {
612
- const avail = availableTargets();
613
- process.stderr.write(
614
- err.fail(
615
- `sentinel: target(s) not available yet: ${summary.skipped.map((t) => `--${t}`).join(", ")}. Available now: ${avail.length ? avail.map((t) => `--${t}`).join(", ") : "(none yet)"}.`
616
- ) + "\n"
617
- );
618
- return 1;
619
- }
620
- return verb === "run" || opts.ci ? worstCode : 0;
621
- }
622
- async function runInit() {
623
- const { modules, scope } = resolveContext(cwd, { module: opts.module, ci: false });
624
- const [module] = modules;
625
- if (scope === "all" || scope === "affected" || !module) {
626
- program.error(
627
- "--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."
628
- );
629
- }
630
- const available = availableTargets();
631
- if (targetsExplicit) {
632
- const unwired = targets.filter((type) => !available.includes(type));
633
- if (unwired.length > 0) {
634
- program.error(
635
- `target(s) not available yet: ${unwired.map((t) => `--${t}`).join(", ")}. Available now: ${available.map((t) => `--${t}`).join(", ") || "(none yet)"}.`
636
- );
637
- }
638
- }
639
- const toRun = targets.filter((type) => available.includes(type));
640
- let worst = 0;
641
- for (const type of toRun) {
642
- try {
643
- const code = await dispatch({
644
- verb,
645
- target: type,
646
- runner: opts.runner,
647
- cwd: module.root,
648
- preset,
649
- dryRun: Boolean(opts.dryRun),
650
- json: Boolean(opts.json)
651
- });
652
- worst = Math.max(worst, code);
653
- } catch (err) {
654
- process.stderr.write(`
655
- sentinel (${type}): ${asMessage(err)}
656
- `);
657
- worst = Math.max(worst, 1);
658
- }
659
- }
660
- if (worst === 0) {
661
- const root = findWorkspaceRoot(module.root);
662
- if (root) {
663
- const changes = ensureWorkspacePrep({ root, dryRun: Boolean(opts.dryRun) });
664
- const prefix = opts.dryRun ? " dry run: " : " ";
665
- for (const change of changes) process.stderr.write(`${prefix}${change}
666
- `);
667
- if (changes.length > 0 && !opts.dryRun) {
668
- process.stderr.write(
669
- palette(process.stderr).dim(" run `pnpm install` to apply the workspace changes\n")
670
- );
671
- }
672
- }
673
- }
674
- return worst;
675
- }
676
634
  async function exitWithoutTruncating(code) {
677
635
  process.exitCode = code;
678
636
  const stream = process.stdout;
@@ -683,13 +641,28 @@ async function main() {
683
641
  if (verb === "migrate") {
684
642
  program.error("--migrate is planned and not available yet; use --init to set a module up.");
685
643
  }
686
- const runSelectedVerb = verb === "init" ? runInit : runVerb;
687
- const exitCode = await runSelectedVerb();
644
+ const context = {
645
+ verb,
646
+ cwd,
647
+ targets,
648
+ targetsExplicit,
649
+ preset,
650
+ toolArgs,
651
+ maxDiagnostics,
652
+ module: opts.module,
653
+ runner: opts.runner,
654
+ ci: Boolean(opts.ci),
655
+ fix: Boolean(opts.fix),
656
+ json: Boolean(opts.json),
657
+ dryRun: Boolean(opts.dryRun),
658
+ fail: (message) => program.error(message)
659
+ };
660
+ const exitCode = verb === "init" ? await runInit(context) : await runVerb(context);
688
661
  await exitWithoutTruncating(exitCode);
689
662
  }
690
663
  main().catch((error) => {
691
664
  process.stderr.write(`
692
- sentinel: ${asMessage(error)}
665
+ sentinel: ${asMessage2(error)}
693
666
  `);
694
667
  void exitWithoutTruncating(1);
695
668
  });