@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 +41 -11
- package/dist/bin/sentinel.js +174 -201
- package/dist/{chunk-FLLX5MN3.js → chunk-NAUACDXT.js} +2675 -1123
- package/dist/index.d.ts +34 -1
- package/dist/index.js +1 -1
- package/oxlint/nest.json +9 -96
- package/oxlint/node.json +1 -7
- package/oxlint/react-lib.json +376 -0
- package/oxlint/react.json +1 -82
- package/oxlint/svelte.json +0 -6
- package/oxlint/tools.json +197 -0
- package/package.json +25 -12
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 **
|
|
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 --
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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.
|
|
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
|
|
package/dist/bin/sentinel.js
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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/
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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 -- <
|
|
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
|
|
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
|
|
687
|
-
|
|
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: ${
|
|
665
|
+
sentinel: ${asMessage2(error)}
|
|
693
666
|
`);
|
|
694
667
|
void exitWithoutTruncating(1);
|
|
695
668
|
});
|