@hublo/sentinel 1.0.2 → 1.1.0-alpha.2
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/dist/bin/sentinel.js +1 -1
- package/dist/chunk-EZCRU6XE.js +2062 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +1 -1
- package/oxlint/nest.json +448 -0
- package/oxlint/node.json +61 -0
- package/oxlint/react.json +570 -0
- package/package.json +9 -2
- package/plugins/hublo.js +88 -0
- package/dist/chunk-SK6E5EM4.js +0 -818
package/dist/chunk-SK6E5EM4.js
DELETED
|
@@ -1,818 +0,0 @@
|
|
|
1
|
-
// src/core/registry.ts
|
|
2
|
-
var adapters = [];
|
|
3
|
-
var defaultRunner = {
|
|
4
|
-
// Filled in by tool branches, e.g. lint: 'eslint', typescript: 'tsc'.
|
|
5
|
-
};
|
|
6
|
-
function register(adapter) {
|
|
7
|
-
adapters.push(adapter);
|
|
8
|
-
}
|
|
9
|
-
function setDefaultRunner(target, runner) {
|
|
10
|
-
defaultRunner[target] = runner;
|
|
11
|
-
}
|
|
12
|
-
function all() {
|
|
13
|
-
return adapters;
|
|
14
|
-
}
|
|
15
|
-
function availableTargets() {
|
|
16
|
-
return [...new Set(adapters.map((a) => a.target))];
|
|
17
|
-
}
|
|
18
|
-
function resolve(target, flavour, runner) {
|
|
19
|
-
const forTarget = adapters.filter((a) => a.target === target);
|
|
20
|
-
if (forTarget.length === 0) {
|
|
21
|
-
throw new Error(
|
|
22
|
-
`No adapter registered for target "${target}" yet (it ships in a later ticket).`
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
const candidates = flavour ? forTarget.filter((a) => a.appliesTo(flavour)) : forTarget;
|
|
26
|
-
if (candidates.length === 0) {
|
|
27
|
-
throw new Error(`No adapter for target "${target}" handles flavour "${flavour}".`);
|
|
28
|
-
}
|
|
29
|
-
const wanted = runner ?? defaultRunner[target];
|
|
30
|
-
const available = candidates.map((a) => a.runner).join(", ");
|
|
31
|
-
if (!wanted) {
|
|
32
|
-
const [first, ...rest] = candidates;
|
|
33
|
-
if (first && rest.length === 0) return first;
|
|
34
|
-
throw new Error(
|
|
35
|
-
`Multiple runners for target "${target}" (${available}); pass --runner or set a default.`
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
const matching = candidates.filter((a) => a.runner === wanted);
|
|
39
|
-
if (matching.length === 0) {
|
|
40
|
-
throw new Error(
|
|
41
|
-
`No runner "${wanted}" for target "${target}" (flavour "${flavour}"). Available: ${available}.`
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
if (matching.length > 1) {
|
|
45
|
-
throw new Error(
|
|
46
|
-
`Ambiguous: ${matching.length} adapters claim target "${target}", runner "${wanted}", flavour "${flavour}".`
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
return matching[0];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// src/core/base-adapter.ts
|
|
53
|
-
var BaseAdapter = class {
|
|
54
|
-
inspect(_ctx) {
|
|
55
|
-
throw new Error(`${this.runner}: --inspect not implemented yet`);
|
|
56
|
-
}
|
|
57
|
-
report(_ctx) {
|
|
58
|
-
throw new Error(`${this.runner}: --report not implemented yet`);
|
|
59
|
-
}
|
|
60
|
-
status(_ctx) {
|
|
61
|
-
throw new Error(`${this.runner}: --status not implemented yet`);
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
// src/core/domain.ts
|
|
66
|
-
var VERBS = ["run", "inspect", "init", "migrate", "report", "status"];
|
|
67
|
-
var TARGETS = [
|
|
68
|
-
"lint",
|
|
69
|
-
"format",
|
|
70
|
-
"typescript",
|
|
71
|
-
"build",
|
|
72
|
-
"test",
|
|
73
|
-
"static-analysis",
|
|
74
|
-
"runtime-analysis",
|
|
75
|
-
"arch"
|
|
76
|
-
];
|
|
77
|
-
var FLAVOURS = ["react", "nest", "svelte", "node"];
|
|
78
|
-
|
|
79
|
-
// src/shared/color.ts
|
|
80
|
-
import { styleText } from "util";
|
|
81
|
-
function palette(stream) {
|
|
82
|
-
const paint = (format, text) => styleText(format, text, { stream });
|
|
83
|
-
return {
|
|
84
|
-
ok: (text) => paint(["green", "bold"], text),
|
|
85
|
-
fail: (text) => paint(["red", "bold"], text),
|
|
86
|
-
warn: (text) => paint("yellow", text),
|
|
87
|
-
strong: (text) => paint("bold", text),
|
|
88
|
-
dim: (text) => paint("dim", text),
|
|
89
|
-
accent: (text) => paint("cyan", text)
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// src/shared/package-json.ts
|
|
94
|
-
import { existsSync, readFileSync } from "fs";
|
|
95
|
-
import { dirname, join } from "path";
|
|
96
|
-
import { fileURLToPath } from "url";
|
|
97
|
-
function readOwnPackage() {
|
|
98
|
-
let dir = dirname(fileURLToPath(import.meta.url));
|
|
99
|
-
for (; ; ) {
|
|
100
|
-
const pkgPath = join(dir, "package.json");
|
|
101
|
-
if (existsSync(pkgPath)) {
|
|
102
|
-
try {
|
|
103
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
104
|
-
if (typeof pkg.version === "string") {
|
|
105
|
-
return { name: pkg.name ?? "@hublo/sentinel", version: pkg.version };
|
|
106
|
-
}
|
|
107
|
-
} catch {
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
const parent = dirname(dir);
|
|
111
|
-
if (parent === dir) return { name: "@hublo/sentinel", version: "0.0.0" };
|
|
112
|
-
dir = parent;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
function readOwnVersion() {
|
|
116
|
-
return readOwnPackage().version;
|
|
117
|
-
}
|
|
118
|
-
function readProjectPackageJson(dir) {
|
|
119
|
-
const path = join(dir, "package.json");
|
|
120
|
-
if (!existsSync(path)) return {};
|
|
121
|
-
try {
|
|
122
|
-
return JSON.parse(readFileSync(path, "utf8"));
|
|
123
|
-
} catch {
|
|
124
|
-
process.stderr.write(`sentinel: could not parse ${path}; ignoring for detection.
|
|
125
|
-
`);
|
|
126
|
-
return {};
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
function readNxProjectName(dir) {
|
|
130
|
-
const path = join(dir, "project.json");
|
|
131
|
-
if (!existsSync(path)) return void 0;
|
|
132
|
-
try {
|
|
133
|
-
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
134
|
-
return typeof parsed.name === "string" ? parsed.name : void 0;
|
|
135
|
-
} catch {
|
|
136
|
-
return void 0;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// src/roles/typescript/adapters/tsc/tsc.adapter.ts
|
|
141
|
-
import { spawnSync } from "child_process";
|
|
142
|
-
import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
|
|
143
|
-
import { basename, join as join5 } from "path";
|
|
144
|
-
|
|
145
|
-
// src/shared/jsonc.ts
|
|
146
|
-
import { parse, printParseErrorCode } from "jsonc-parser";
|
|
147
|
-
function parseJsonc(text, source = "config") {
|
|
148
|
-
const errors = [];
|
|
149
|
-
const value = parse(text, errors, { allowTrailingComma: true });
|
|
150
|
-
if (errors.length > 0) {
|
|
151
|
-
const details = errors.map((error) => printParseErrorCode(error.error)).join(", ");
|
|
152
|
-
throw new Error(`${source}: malformed JSONC (${details}).`);
|
|
153
|
-
}
|
|
154
|
-
return value;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// src/shared/resolve-bin.ts
|
|
158
|
-
import { existsSync as existsSync2 } from "fs";
|
|
159
|
-
import { dirname as dirname2, join as join2 } from "path";
|
|
160
|
-
function resolveBin(fromDir, name) {
|
|
161
|
-
let dir = fromDir;
|
|
162
|
-
for (; ; ) {
|
|
163
|
-
const candidate = join2(dir, "node_modules", ".bin", name);
|
|
164
|
-
if (existsSync2(candidate)) return candidate;
|
|
165
|
-
const parent = dirname2(dir);
|
|
166
|
-
if (parent === dir) return void 0;
|
|
167
|
-
dir = parent;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// src/roles/typescript/config-policy.ts
|
|
172
|
-
var PERMITTED_COMPILER_OPTIONS = [
|
|
173
|
-
"paths",
|
|
174
|
-
"baseUrl",
|
|
175
|
-
"rootDir",
|
|
176
|
-
"outDir",
|
|
177
|
-
"tsBuildInfoFile"
|
|
178
|
-
];
|
|
179
|
-
function presetOwnedKeys(compilerOptions) {
|
|
180
|
-
if (!compilerOptions) return [];
|
|
181
|
-
return Object.keys(compilerOptions).filter((key) => !PERMITTED_COMPILER_OPTIONS.includes(key));
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// src/roles/typescript/phased-rules.ts
|
|
185
|
-
var DEFERRED_RULES = [
|
|
186
|
-
{ rule: "noImplicitAny", phase: 2, reason: "the implicit-any migration (TS70xx)" },
|
|
187
|
-
{ rule: "noUnusedLocals", phase: 2, reason: "unused-local cleanup (TS6133)" },
|
|
188
|
-
{ rule: "noUnusedParameters", phase: 2, reason: "unused-parameter cleanup (TS6133)" }
|
|
189
|
-
];
|
|
190
|
-
function deferredRuleNames() {
|
|
191
|
-
return DEFERRED_RULES.map((entry) => entry.rule);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// src/roles/typescript/presets.ts
|
|
195
|
-
var SHIPPED_FLAVOURS = ["react", "nest", "node"];
|
|
196
|
-
function hasShippedPreset(flavour) {
|
|
197
|
-
return SHIPPED_FLAVOURS.includes(flavour);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// src/roles/typescript/read-adoption.ts
|
|
201
|
-
import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
|
|
202
|
-
import { join as join4 } from "path";
|
|
203
|
-
|
|
204
|
-
// src/roles/typescript/resolve-tsconfig-target.ts
|
|
205
|
-
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
206
|
-
import { join as join3 } from "path";
|
|
207
|
-
var TARGET_EXTENDS_MARKERS = ["tsconfig.base.json", "@hublo/sentinel/tsconfig/"];
|
|
208
|
-
var CANDIDATES = ["tsconfig.app.json", "tsconfig.json"];
|
|
209
|
-
function readExtends(absolutePath) {
|
|
210
|
-
let parsed;
|
|
211
|
-
try {
|
|
212
|
-
parsed = parseJsonc(readFileSync2(absolutePath, "utf8"), absolutePath);
|
|
213
|
-
} catch {
|
|
214
|
-
return [];
|
|
215
|
-
}
|
|
216
|
-
if (typeof parsed.extends === "string") return [parsed.extends];
|
|
217
|
-
if (Array.isArray(parsed.extends)) {
|
|
218
|
-
return parsed.extends.filter((entry) => typeof entry === "string");
|
|
219
|
-
}
|
|
220
|
-
return [];
|
|
221
|
-
}
|
|
222
|
-
function resolveTsconfigTarget(moduleDir) {
|
|
223
|
-
let existing;
|
|
224
|
-
for (const candidate of CANDIDATES) {
|
|
225
|
-
const absolutePath = join3(moduleDir, candidate);
|
|
226
|
-
if (!existsSync3(absolutePath)) continue;
|
|
227
|
-
existing ??= candidate;
|
|
228
|
-
const extendsValues = readExtends(absolutePath);
|
|
229
|
-
const extendsBase = extendsValues.some(
|
|
230
|
-
(value) => TARGET_EXTENDS_MARKERS.some((marker) => value.includes(marker))
|
|
231
|
-
);
|
|
232
|
-
if (extendsBase) {
|
|
233
|
-
return { path: candidate, reason: "extends-base" };
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
if (existing) return { path: existing, reason: "other-chain" };
|
|
237
|
-
return { path: "tsconfig.json", reason: "none" };
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// src/roles/typescript/read-adoption.ts
|
|
241
|
-
var SENTINEL_PRESET = /^@hublo\/sentinel\/tsconfig\/[a-z-]+$/;
|
|
242
|
-
function normaliseExtends(value) {
|
|
243
|
-
if (typeof value === "string") return [value];
|
|
244
|
-
if (Array.isArray(value))
|
|
245
|
-
return value.filter((entry) => typeof entry === "string");
|
|
246
|
-
return [];
|
|
247
|
-
}
|
|
248
|
-
var NOT_ADOPTED = (configFile) => ({
|
|
249
|
-
configFile,
|
|
250
|
-
preset: null,
|
|
251
|
-
adopted: false,
|
|
252
|
-
conformant: false,
|
|
253
|
-
drift: []
|
|
254
|
-
});
|
|
255
|
-
function readTsconfigAdoption(cwd) {
|
|
256
|
-
const target = resolveTsconfigTarget(cwd);
|
|
257
|
-
if (target.reason === "none" || !existsSync4(join4(cwd, target.path))) {
|
|
258
|
-
return NOT_ADOPTED(target.reason === "none" ? null : target.path);
|
|
259
|
-
}
|
|
260
|
-
let parsed;
|
|
261
|
-
try {
|
|
262
|
-
parsed = parseJsonc(readFileSync3(join4(cwd, target.path), "utf8"), target.path);
|
|
263
|
-
} catch {
|
|
264
|
-
return NOT_ADOPTED(target.path);
|
|
265
|
-
}
|
|
266
|
-
const preset = normaliseExtends(parsed.extends).find((entry) => SENTINEL_PRESET.test(entry)) ?? null;
|
|
267
|
-
if (preset === null) return NOT_ADOPTED(target.path);
|
|
268
|
-
const drift = presetOwnedKeys(parsed.compilerOptions);
|
|
269
|
-
return { configFile: target.path, preset, adopted: true, conformant: drift.length === 0, drift };
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// src/roles/typescript/adapters/tsc/tsc.adapter.ts
|
|
273
|
-
var TYPECHECK_SCRIPT = { typecheck: "sentinel --run --typescript" };
|
|
274
|
-
var INSTALL_NOTE = "run `pnpm install` to fetch @hublo/sentinel (added to the module devDependencies) so `extends` and the typecheck script resolve";
|
|
275
|
-
var DEFAULT_MAX_DIAGNOSTICS = 100;
|
|
276
|
-
var DIAGNOSTIC_RE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.+)$/;
|
|
277
|
-
function parseDiagnostics(output) {
|
|
278
|
-
const diagnostics = [];
|
|
279
|
-
for (const raw of output.split("\n")) {
|
|
280
|
-
const m = DIAGNOSTIC_RE.exec(raw.trim());
|
|
281
|
-
if (m) {
|
|
282
|
-
diagnostics.push({
|
|
283
|
-
file: m[1],
|
|
284
|
-
line: Number(m[2]),
|
|
285
|
-
col: Number(m[3]),
|
|
286
|
-
code: m[4],
|
|
287
|
-
message: m[5]
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
return diagnostics;
|
|
292
|
-
}
|
|
293
|
-
var PHASED_STRICTNESS_WARNING = `sentinel typescript: phase 1 (non-breaking) \u2014 deferred: ${deferredRuleNames().join(", ")}. Enabled centrally in a later wave; run \`sentinel --inspect --typescript\` for the list.`;
|
|
294
|
-
function composeExtends(current, preset) {
|
|
295
|
-
const chain = typeof current === "string" ? [current] : Array.isArray(current) ? current.filter((entry) => typeof entry === "string") : [];
|
|
296
|
-
return chain.includes(preset) ? chain : [...chain, preset];
|
|
297
|
-
}
|
|
298
|
-
var TscAdapter = class extends BaseAdapter {
|
|
299
|
-
target = "typescript";
|
|
300
|
-
runner = "tsc";
|
|
301
|
-
/**
|
|
302
|
-
* The tsc adapter drives type-checking for any flavour: `--run`/`--report`/
|
|
303
|
-
* `--inspect` just execute tsc against the module's existing config, which is
|
|
304
|
-
* meaningful regardless of flavour. `--init` is the exception, it only WRITES a
|
|
305
|
-
* preset for flavours that ship one (gated inside `plan`), so svelte is not
|
|
306
|
-
* clobbered with a non-existent preset.
|
|
307
|
-
*/
|
|
308
|
-
appliesTo(_flavour) {
|
|
309
|
-
return true;
|
|
310
|
-
}
|
|
311
|
-
/**
|
|
312
|
-
* The flavour read from the committed `extends` chain
|
|
313
|
-
* (`@hublo/sentinel/tsconfig/nest` -> `nest`), or undefined when the module has not
|
|
314
|
-
* adopted a preset, so the engine falls back to dependency detection.
|
|
315
|
-
*
|
|
316
|
-
* This is the same detection-free read `--status` uses, and it is why an adopted React app
|
|
317
|
-
* reports `react` even in a monorepo that hoists `react` to the root.
|
|
318
|
-
*/
|
|
319
|
-
declaredFlavour(cwd) {
|
|
320
|
-
const { preset } = readTsconfigAdoption(cwd);
|
|
321
|
-
if (!preset) return void 0;
|
|
322
|
-
const name = preset.slice(preset.lastIndexOf("/") + 1);
|
|
323
|
-
return FLAVOURS.includes(name) ? name : void 0;
|
|
324
|
-
}
|
|
325
|
-
/**
|
|
326
|
-
* Plan `--init`: make the module extend the sentinel preset with a THIN,
|
|
327
|
-
* conformant stub, route type-checking through the CLI, and pin the
|
|
328
|
-
* `@hublo/sentinel` devDependency into the module. The engine applies the ops, so
|
|
329
|
-
* adoption is `--init` then `pnpm install`, with nothing to add by hand.
|
|
330
|
-
*
|
|
331
|
-
* Per resolved case:
|
|
332
|
-
* - extends-base: append the preset to the `extends` chain (keep the base for
|
|
333
|
-
* the monorepo's paths/structure) + strip preset-owned `compilerOptions`
|
|
334
|
-
* (drift), keeping the project's own paths/include (the allowlist).
|
|
335
|
-
* - none: create a fresh thin `tsconfig.json`.
|
|
336
|
-
* - other-chain (svelte): skip, its config extends a different base.
|
|
337
|
-
*/
|
|
338
|
-
plan(context) {
|
|
339
|
-
if (!hasShippedPreset(context.flavour)) {
|
|
340
|
-
return {
|
|
341
|
-
operations: [],
|
|
342
|
-
blocked: `no TypeScript preset for flavour "${context.flavour}" yet (shipped: ${SHIPPED_FLAVOURS.join(", ")}). Nothing was written; this module cannot adopt the TypeScript preset until that flavour ships.`
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
const target = resolveTsconfigTarget(context.cwd);
|
|
346
|
-
const preset = `@hublo/sentinel/tsconfig/${context.flavour}`;
|
|
347
|
-
const addScript = this.packageJsonOperation(context.cwd);
|
|
348
|
-
if (target.reason === "other-chain") {
|
|
349
|
-
return {
|
|
350
|
-
operations: [],
|
|
351
|
-
blocked: `${target.path} extends a config chain sentinel does not handle, so nothing was written. Point it at the workspace base (or a plain config) and re-run.`
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
if (target.reason === "none") {
|
|
355
|
-
const contents = JSON.stringify({ extends: preset, include: ["src"] }, null, 2) + "\n";
|
|
356
|
-
return {
|
|
357
|
-
operations: [{ kind: "write", path: target.path, contents }, addScript],
|
|
358
|
-
notes: [`created ${target.path} (no tsconfig found)`, INSTALL_NOTE]
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
const existing = parseJsonc(
|
|
362
|
-
readFileSync4(join5(context.cwd, target.path), "utf8"),
|
|
363
|
-
target.path
|
|
364
|
-
);
|
|
365
|
-
const extendsChain = composeExtends(existing.extends, preset);
|
|
366
|
-
const drift = presetOwnedKeys(existing.compilerOptions);
|
|
367
|
-
const operations = [
|
|
368
|
-
{ kind: "merge-json", path: target.path, value: { extends: extendsChain } }
|
|
369
|
-
];
|
|
370
|
-
const notes = [];
|
|
371
|
-
if (drift.length > 0) {
|
|
372
|
-
const localKeys = Object.keys(existing.compilerOptions ?? {});
|
|
373
|
-
const stripsAll = localKeys.length > 0 && localKeys.every((key) => drift.includes(key));
|
|
374
|
-
operations.push({
|
|
375
|
-
kind: "remove-json-keys",
|
|
376
|
-
path: target.path,
|
|
377
|
-
keys: stripsAll ? [["compilerOptions"]] : drift.map((key) => ["compilerOptions", key])
|
|
378
|
-
});
|
|
379
|
-
notes.push(`stripped preset-owned compilerOptions: ${drift.join(", ")}`);
|
|
380
|
-
}
|
|
381
|
-
operations.push(addScript);
|
|
382
|
-
notes.push(INSTALL_NOTE);
|
|
383
|
-
return { operations, notes };
|
|
384
|
-
}
|
|
385
|
-
/**
|
|
386
|
-
* The op that routes type-checking through the CLI. If the module already has a
|
|
387
|
-
* `package.json`, merge the script in and leave the rest untouched. If it does
|
|
388
|
-
* NOT (common for nx apps/services that carry only a `project.json`), scaffold a
|
|
389
|
-
* minimal, workspace-valid one, its nx name + `private: true`, so pnpm accepts it
|
|
390
|
-
* and it can carry the pinned `@hublo/sentinel` devDep this op also writes. The
|
|
391
|
-
* exact version is written (never a range), so `pnpm install` resolves the same
|
|
392
|
-
* build the stub's `extends` points at.
|
|
393
|
-
*/
|
|
394
|
-
packageJsonOperation(cwd) {
|
|
395
|
-
const own = readOwnPackage();
|
|
396
|
-
const devDependencies = { [own.name]: own.version };
|
|
397
|
-
if (existsSync5(join5(cwd, "package.json"))) {
|
|
398
|
-
return {
|
|
399
|
-
kind: "merge-json",
|
|
400
|
-
path: "package.json",
|
|
401
|
-
value: { scripts: TYPECHECK_SCRIPT, devDependencies }
|
|
402
|
-
};
|
|
403
|
-
}
|
|
404
|
-
const name = readNxProjectName(cwd) ?? basename(cwd);
|
|
405
|
-
return {
|
|
406
|
-
kind: "merge-json",
|
|
407
|
-
path: "package.json",
|
|
408
|
-
value: { name, private: true, scripts: TYPECHECK_SCRIPT, devDependencies }
|
|
409
|
-
};
|
|
410
|
-
}
|
|
411
|
-
/**
|
|
412
|
-
* Type-check the module with `tsc -b` (build mode) on its solution config, the
|
|
413
|
-
* way the monorepo itself does. Build mode walks the config's `references`, so a
|
|
414
|
-
* references-only solution (Pattern A: app + spec) is actually checked instead of
|
|
415
|
-
* passing vacuously; it also only caches SUCCESSFUL builds, so errors are always
|
|
416
|
-
* re-reported. Uses the module's own tsc. Nothing to check is a pass.
|
|
417
|
-
*/
|
|
418
|
-
async run(ctx) {
|
|
419
|
-
const config = this.typecheckTarget(ctx.cwd);
|
|
420
|
-
if (!config) {
|
|
421
|
-
process.stderr.write("sentinel typescript(tsc): no tsconfig to check\n");
|
|
422
|
-
return { ok: true, code: 0 };
|
|
423
|
-
}
|
|
424
|
-
process.stderr.write(`${palette(process.stderr).warn(PHASED_STRICTNESS_WARNING)}
|
|
425
|
-
`);
|
|
426
|
-
const tsc = resolveBin(ctx.cwd, "tsc") ?? "tsc";
|
|
427
|
-
const result = spawnSync(tsc, ["-b", config], { cwd: ctx.cwd, stdio: "inherit" });
|
|
428
|
-
if (result.error) {
|
|
429
|
-
process.stderr.write(
|
|
430
|
-
`sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
|
|
431
|
-
`
|
|
432
|
-
);
|
|
433
|
-
return { ok: false, code: 1 };
|
|
434
|
-
}
|
|
435
|
-
const code = result.status ?? 1;
|
|
436
|
-
return { ok: code === 0, code };
|
|
437
|
-
}
|
|
438
|
-
/**
|
|
439
|
-
* The config to type-check with `tsc -b`. Prefer the module's root `tsconfig.json`
|
|
440
|
-
* (the solution the monorepo builds; `tsc -b` follows its `references` to cover
|
|
441
|
-
* app + spec), else the base-extending file, else null when there is nothing to
|
|
442
|
-
* check.
|
|
443
|
-
*/
|
|
444
|
-
typecheckTarget(cwd) {
|
|
445
|
-
if (existsSync5(join5(cwd, "tsconfig.json"))) return "tsconfig.json";
|
|
446
|
-
const target = resolveTsconfigTarget(cwd);
|
|
447
|
-
return target.reason === "none" ? null : target.path;
|
|
448
|
-
}
|
|
449
|
-
/**
|
|
450
|
-
* The module's resolved TypeScript config: which preset, which file, how, and the
|
|
451
|
-
* phased-strictness state (`deferred` rules that are off in phase 1). This is the
|
|
452
|
-
* "list what's deferred" query, `sentinel --inspect --typescript`.
|
|
453
|
-
*/
|
|
454
|
-
async inspect(ctx) {
|
|
455
|
-
const target = resolveTsconfigTarget(ctx.cwd);
|
|
456
|
-
const { preset, adopted } = readTsconfigAdoption(ctx.cwd);
|
|
457
|
-
return {
|
|
458
|
-
module: ctx.module,
|
|
459
|
-
target: "typescript",
|
|
460
|
-
flavour: ctx.flavour,
|
|
461
|
-
configFile: target.path,
|
|
462
|
-
configState: target.reason,
|
|
463
|
-
preset,
|
|
464
|
-
adopted,
|
|
465
|
-
phase: 1,
|
|
466
|
-
deferred: DEFERRED_RULES
|
|
467
|
-
};
|
|
468
|
-
}
|
|
469
|
-
/**
|
|
470
|
-
* Adoption + conformity from the committed tsconfig, for `--status`. No tsc run and
|
|
471
|
-
* no flavour guessing: reads the actual `extends` chain, so a workspace-wide scan is
|
|
472
|
-
* a cheap coverage + drift dashboard (adopted? which preset? drifted?).
|
|
473
|
-
*/
|
|
474
|
-
async status(ctx) {
|
|
475
|
-
const { adopted, preset, conformant, drift } = readTsconfigAdoption(ctx.cwd);
|
|
476
|
-
return { adopted, preset, conformant, drift };
|
|
477
|
-
}
|
|
478
|
-
/**
|
|
479
|
-
* Report conformance for the module: `tsc -b` (build mode, so app + spec are
|
|
480
|
-
* covered) and count total type errors plus the implicit-`any` family (TS70xx:
|
|
481
|
-
* 7006/7031/7053/… ), the signal that drives the noImplicitAny migration. No
|
|
482
|
-
* tsconfig is a clean, empty report.
|
|
483
|
-
*/
|
|
484
|
-
async report(ctx) {
|
|
485
|
-
const config = this.typecheckTarget(ctx.cwd);
|
|
486
|
-
if (!config) {
|
|
487
|
-
return { ok: true, code: 0, metrics: { errors: 0, implicitAny: "deferred", diagnostics: [] } };
|
|
488
|
-
}
|
|
489
|
-
const tsc = resolveBin(ctx.cwd, "tsc") ?? "tsc";
|
|
490
|
-
const result = spawnSync(tsc, ["-b", config], { cwd: ctx.cwd, encoding: "utf8" });
|
|
491
|
-
if (result.error) {
|
|
492
|
-
process.stderr.write(
|
|
493
|
-
`sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
|
|
494
|
-
`
|
|
495
|
-
);
|
|
496
|
-
return { ok: false, code: 1, metrics: { error: "tsc not available" } };
|
|
497
|
-
}
|
|
498
|
-
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
499
|
-
const errors = (output.match(/error TS\d+/g) ?? []).length;
|
|
500
|
-
const parsed = parseDiagnostics(output);
|
|
501
|
-
const cap = ctx.maxDiagnostics === 0 ? Infinity : ctx.maxDiagnostics ?? DEFAULT_MAX_DIAGNOSTICS;
|
|
502
|
-
const diagnostics = Number.isFinite(cap) ? parsed.slice(0, cap) : parsed;
|
|
503
|
-
return {
|
|
504
|
-
ok: errors === 0,
|
|
505
|
-
code: result.status ?? 0,
|
|
506
|
-
metrics: {
|
|
507
|
-
errors,
|
|
508
|
-
implicitAny: this.implicitAnyMetric(ctx.cwd, tsc, config, output),
|
|
509
|
-
diagnostics,
|
|
510
|
-
diagnosticsTruncated: diagnostics.length < parsed.length
|
|
511
|
-
}
|
|
512
|
-
};
|
|
513
|
-
}
|
|
514
|
-
/**
|
|
515
|
-
* The `implicitAny` report metric, honestly. Implicit-`any` violations (TS70xx) are
|
|
516
|
-
* only *visible* to tsc when `noImplicitAny` is ON. In phase 1 the rule is DEFERRED
|
|
517
|
-
* (off), so counting TS70xx from the committed-config run is structurally always 0 —
|
|
518
|
-
* a misleading "no implicit-any" when the rule simply was not applied. So: report the
|
|
519
|
-
* real count only when the rule is on, otherwise `'deferred'` (never a fake `0`). The
|
|
520
|
-
* remaining debt while deferred is a separate, opt-in probe (a later `--migration`).
|
|
521
|
-
*/
|
|
522
|
-
implicitAnyMetric(cwd, tsc, config, mainOutput) {
|
|
523
|
-
return this.noImplicitAnyEnabled(cwd, tsc, config, mainOutput) ? (mainOutput.match(/error TS70\d\d/g) ?? []).length : "deferred";
|
|
524
|
-
}
|
|
525
|
-
/**
|
|
526
|
-
* Whether `noImplicitAny` is effectively ON in the module's resolved config. Read from
|
|
527
|
-
* `tsc --showConfig` (an explicit value wins; otherwise `strict` implies it). If
|
|
528
|
-
* `--showConfig` is unavailable, fall back to the run's own evidence: implicit-`any`
|
|
529
|
-
* errors in the output mean the rule must be on.
|
|
530
|
-
*/
|
|
531
|
-
noImplicitAnyEnabled(cwd, tsc, config, mainOutput) {
|
|
532
|
-
const shown = spawnSync(tsc, ["-p", config, "--showConfig"], { cwd, encoding: "utf8" });
|
|
533
|
-
if (shown.status === 0 && shown.stdout) {
|
|
534
|
-
try {
|
|
535
|
-
const co = parseJsonc(
|
|
536
|
-
shown.stdout,
|
|
537
|
-
"tsconfig(--showConfig)"
|
|
538
|
-
).compilerOptions;
|
|
539
|
-
if (co) return co.noImplicitAny ?? co.strict === true;
|
|
540
|
-
} catch {
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
return /error TS70\d\d/.test(mainOutput);
|
|
544
|
-
}
|
|
545
|
-
};
|
|
546
|
-
|
|
547
|
-
// src/roles/typescript/register.ts
|
|
548
|
-
function registerTypescript() {
|
|
549
|
-
register(new TscAdapter());
|
|
550
|
-
setDefaultRunner("typescript", "tsc");
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// src/adapters.ts
|
|
554
|
-
function registerAdapters() {
|
|
555
|
-
registerTypescript();
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// src/core/detect-framework.ts
|
|
559
|
-
var FRAMEWORK_SIGNALS = [
|
|
560
|
-
{ flavour: "nest", dependency: "@nestjs/core" },
|
|
561
|
-
{ flavour: "react", dependency: "react" },
|
|
562
|
-
{ flavour: "svelte", dependency: "svelte" }
|
|
563
|
-
];
|
|
564
|
-
var BACKEND_HINTS = ["sails", "express", "koa", "fastify", "@hapi/hapi"];
|
|
565
|
-
function describeFramework(packageJson) {
|
|
566
|
-
const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies };
|
|
567
|
-
const matched = FRAMEWORK_SIGNALS.filter((s) => s.dependency in dependencies);
|
|
568
|
-
const chosen = matched[0];
|
|
569
|
-
const flavour = chosen?.flavour ?? "node";
|
|
570
|
-
const source = chosen?.dependency ?? "(no framework dependency)";
|
|
571
|
-
const otherFlavourSignals = matched.slice(1).map((s) => s.dependency);
|
|
572
|
-
const backendHints = flavour === "react" || flavour === "svelte" ? BACKEND_HINTS.filter((hint) => hint in dependencies) : [];
|
|
573
|
-
const conflicts = [...otherFlavourSignals, ...backendHints];
|
|
574
|
-
return { flavour, source, ambiguous: conflicts.length > 0, conflicts };
|
|
575
|
-
}
|
|
576
|
-
function detectFramework(packageJson) {
|
|
577
|
-
return describeFramework(packageJson).flavour;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// src/shared/text.ts
|
|
581
|
-
function ensureLines(current, lines) {
|
|
582
|
-
const present = new Set(current.split("\n").map((line) => line.trim()));
|
|
583
|
-
const missing = lines.filter((line) => !present.has(line.trim()));
|
|
584
|
-
if (missing.length === 0) return current;
|
|
585
|
-
const prefix = current.length === 0 || current.endsWith("\n") ? current : current + "\n";
|
|
586
|
-
return prefix + missing.join("\n") + "\n";
|
|
587
|
-
}
|
|
588
|
-
function toLines(text) {
|
|
589
|
-
if (text.length === 0) return [];
|
|
590
|
-
return text.replace(/\n$/, "").split("\n");
|
|
591
|
-
}
|
|
592
|
-
function diffLines(before, after) {
|
|
593
|
-
const from = toLines(before);
|
|
594
|
-
const to = toLines(after);
|
|
595
|
-
const lcs = Array.from(
|
|
596
|
-
{ length: from.length + 1 },
|
|
597
|
-
() => new Array(to.length + 1).fill(0)
|
|
598
|
-
);
|
|
599
|
-
const cell = (i2, j2) => lcs[i2]?.[j2] ?? 0;
|
|
600
|
-
for (let i2 = from.length - 1; i2 >= 0; i2--) {
|
|
601
|
-
const row = lcs[i2];
|
|
602
|
-
if (!row) continue;
|
|
603
|
-
for (let j2 = to.length - 1; j2 >= 0; j2--) {
|
|
604
|
-
row[j2] = from[i2] === to[j2] ? cell(i2 + 1, j2 + 1) + 1 : Math.max(cell(i2 + 1, j2), cell(i2, j2 + 1));
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
const out = [];
|
|
608
|
-
let i = 0;
|
|
609
|
-
let j = 0;
|
|
610
|
-
while (i < from.length && j < to.length) {
|
|
611
|
-
if (from[i] === to[j]) {
|
|
612
|
-
out.push(` ${from[i] ?? ""}`);
|
|
613
|
-
i++;
|
|
614
|
-
j++;
|
|
615
|
-
} else if (cell(i + 1, j) >= cell(i, j + 1)) {
|
|
616
|
-
out.push(`- ${from[i] ?? ""}`);
|
|
617
|
-
i++;
|
|
618
|
-
} else {
|
|
619
|
-
out.push(`+ ${to[j] ?? ""}`);
|
|
620
|
-
j++;
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
while (i < from.length) out.push(`- ${from[i++] ?? ""}`);
|
|
624
|
-
while (j < to.length) out.push(`+ ${to[j++] ?? ""}`);
|
|
625
|
-
return out;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// src/core/apply-plan.ts
|
|
629
|
-
import { existsSync as existsSync6, readFileSync as readFileSync5, renameSync, writeFileSync } from "fs";
|
|
630
|
-
import { resolve as resolve2, sep } from "path";
|
|
631
|
-
import { applyEdits, modify } from "jsonc-parser";
|
|
632
|
-
|
|
633
|
-
// src/shared/deep-merge.ts
|
|
634
|
-
function isPlainObject(value) {
|
|
635
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
// src/core/apply-plan.ts
|
|
639
|
-
function resolveWithinRoot(cwd, relativePath) {
|
|
640
|
-
const root = resolve2(cwd);
|
|
641
|
-
const absolutePath = resolve2(root, relativePath);
|
|
642
|
-
if (absolutePath !== root && !absolutePath.startsWith(root + sep)) {
|
|
643
|
-
throw new Error(`Refusing to write outside the module root: "${relativePath}".`);
|
|
644
|
-
}
|
|
645
|
-
return absolutePath;
|
|
646
|
-
}
|
|
647
|
-
function readIfExists(absolutePath) {
|
|
648
|
-
return existsSync6(absolutePath) ? readFileSync5(absolutePath, "utf8") : void 0;
|
|
649
|
-
}
|
|
650
|
-
function* leaves(value, prefix = []) {
|
|
651
|
-
for (const [key, keyValue] of Object.entries(value)) {
|
|
652
|
-
const path = [...prefix, key];
|
|
653
|
-
if (isPlainObject(keyValue)) yield* leaves(keyValue, path);
|
|
654
|
-
else yield [path, keyValue];
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
function mergeJsonc(current, value) {
|
|
658
|
-
let text = current.trim().length > 0 ? current : "{}\n";
|
|
659
|
-
for (const [path, leaf] of leaves(value)) {
|
|
660
|
-
const edits = modify(text, path, leaf, {
|
|
661
|
-
formattingOptions: { insertSpaces: true, tabSize: 2 }
|
|
662
|
-
});
|
|
663
|
-
text = applyEdits(text, edits);
|
|
664
|
-
}
|
|
665
|
-
return text.endsWith("\n") ? text : text + "\n";
|
|
666
|
-
}
|
|
667
|
-
function removeJsoncKeys(current, keys) {
|
|
668
|
-
let text = current.trim().length > 0 ? current : "{}\n";
|
|
669
|
-
for (const path of keys) {
|
|
670
|
-
const edits = modify(text, path, void 0, {
|
|
671
|
-
formattingOptions: { insertSpaces: true, tabSize: 2 }
|
|
672
|
-
});
|
|
673
|
-
text = applyEdits(text, edits);
|
|
674
|
-
}
|
|
675
|
-
return text.endsWith("\n") ? text : text + "\n";
|
|
676
|
-
}
|
|
677
|
-
function applyOperationTo(current, operation) {
|
|
678
|
-
switch (operation.kind) {
|
|
679
|
-
case "write":
|
|
680
|
-
return operation.contents;
|
|
681
|
-
case "merge-json":
|
|
682
|
-
return mergeJsonc(current, operation.value);
|
|
683
|
-
case "ensure-lines":
|
|
684
|
-
return ensureLines(current, operation.lines);
|
|
685
|
-
case "remove-json-keys":
|
|
686
|
-
return removeJsoncKeys(current, operation.keys);
|
|
687
|
-
default: {
|
|
688
|
-
const unreachable = operation;
|
|
689
|
-
throw new Error(`Unknown file operation: ${JSON.stringify(unreachable)}`);
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
function preparePlan(cwd, plan) {
|
|
694
|
-
const prepared = /* @__PURE__ */ new Map();
|
|
695
|
-
for (const operation of plan.operations) {
|
|
696
|
-
const absolutePath = resolveWithinRoot(cwd, operation.path);
|
|
697
|
-
const existing = prepared.get(operation.path);
|
|
698
|
-
const before = existing?.before ?? readIfExists(absolutePath) ?? "";
|
|
699
|
-
const current = existing?.after ?? before;
|
|
700
|
-
prepared.set(operation.path, {
|
|
701
|
-
path: operation.path,
|
|
702
|
-
absolutePath,
|
|
703
|
-
before,
|
|
704
|
-
after: applyOperationTo(current, operation)
|
|
705
|
-
});
|
|
706
|
-
}
|
|
707
|
-
return [...prepared.values()];
|
|
708
|
-
}
|
|
709
|
-
function writeFileAtomic(absolutePath, contents) {
|
|
710
|
-
const tempPath = `${absolutePath}.sentinel-${process.pid}.tmp`;
|
|
711
|
-
writeFileSync(tempPath, contents);
|
|
712
|
-
renameSync(tempPath, absolutePath);
|
|
713
|
-
}
|
|
714
|
-
function applyPlan(cwd, plan) {
|
|
715
|
-
const changed = preparePlan(cwd, plan).filter((file) => file.before !== file.after);
|
|
716
|
-
for (const file of changed) writeFileAtomic(file.absolutePath, file.after);
|
|
717
|
-
return changed.map((file) => file.path);
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
// src/core/dispatch.ts
|
|
721
|
-
function resolveFlavour(opts) {
|
|
722
|
-
if (opts.flavour) return opts.flavour;
|
|
723
|
-
const detection = describeFramework(readProjectPackageJson(opts.cwd));
|
|
724
|
-
if (detection.ambiguous) {
|
|
725
|
-
const warn = palette(process.stderr);
|
|
726
|
-
process.stderr.write(
|
|
727
|
-
warn.warn(
|
|
728
|
-
`sentinel: flavour is ambiguous, detected "${detection.flavour}" (from ${detection.source}), also found ${detection.conflicts.join(", ")}. Pass --flavour to write the intended preset.`
|
|
729
|
-
) + "\n"
|
|
730
|
-
);
|
|
731
|
-
}
|
|
732
|
-
return detection.flavour;
|
|
733
|
-
}
|
|
734
|
-
function previewPlan(opts, plan) {
|
|
735
|
-
const changed = preparePlan(opts.cwd, plan).filter((file) => file.before !== file.after);
|
|
736
|
-
if (opts.json) {
|
|
737
|
-
process.stdout.write(
|
|
738
|
-
JSON.stringify(
|
|
739
|
-
{
|
|
740
|
-
dryRun: true,
|
|
741
|
-
notes: plan.notes ?? [],
|
|
742
|
-
files: changed.map(({ path, before, after }) => ({
|
|
743
|
-
path,
|
|
744
|
-
action: before.length === 0 ? "create" : "update",
|
|
745
|
-
before,
|
|
746
|
-
after
|
|
747
|
-
}))
|
|
748
|
-
},
|
|
749
|
-
null,
|
|
750
|
-
2
|
|
751
|
-
) + "\n"
|
|
752
|
-
);
|
|
753
|
-
return 0;
|
|
754
|
-
}
|
|
755
|
-
process.stderr.write(" dry run: no files written\n");
|
|
756
|
-
for (const note of plan.notes ?? []) process.stderr.write(` ${note}
|
|
757
|
-
`);
|
|
758
|
-
if (changed.length === 0) {
|
|
759
|
-
process.stderr.write(" nothing to change\n");
|
|
760
|
-
return 0;
|
|
761
|
-
}
|
|
762
|
-
for (const { path, before, after } of changed) {
|
|
763
|
-
const action = before.length === 0 ? "create" : "update";
|
|
764
|
-
process.stdout.write(`
|
|
765
|
-
${action} ${path}
|
|
766
|
-
`);
|
|
767
|
-
for (const line of diffLines(before, after)) process.stdout.write(` ${line}
|
|
768
|
-
`);
|
|
769
|
-
}
|
|
770
|
-
return 0;
|
|
771
|
-
}
|
|
772
|
-
async function dispatch(opts) {
|
|
773
|
-
if (opts.verb !== "init") {
|
|
774
|
-
throw new Error(`dispatch handles --init only; --${opts.verb} routes through analyse()`);
|
|
775
|
-
}
|
|
776
|
-
const detected = resolveFlavour(opts);
|
|
777
|
-
const adapter = resolve(opts.target, detected, opts.runner);
|
|
778
|
-
const flavour = opts.flavour ?? adapter.declaredFlavour?.(opts.cwd) ?? detected;
|
|
779
|
-
const context = { cwd: opts.cwd, flavour };
|
|
780
|
-
const plan = await adapter.plan(context);
|
|
781
|
-
if (plan.blocked) {
|
|
782
|
-
process.stderr.write(`sentinel (${opts.target}): ${plan.blocked}
|
|
783
|
-
`);
|
|
784
|
-
return 1;
|
|
785
|
-
}
|
|
786
|
-
if (opts.dryRun) {
|
|
787
|
-
return previewPlan(opts, plan);
|
|
788
|
-
}
|
|
789
|
-
const written = applyPlan(opts.cwd, plan);
|
|
790
|
-
for (const path of written) process.stderr.write(` wrote ${path}
|
|
791
|
-
`);
|
|
792
|
-
for (const note of plan.notes ?? []) process.stderr.write(` ${note}
|
|
793
|
-
`);
|
|
794
|
-
return 0;
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
export {
|
|
798
|
-
register,
|
|
799
|
-
setDefaultRunner,
|
|
800
|
-
all,
|
|
801
|
-
availableTargets,
|
|
802
|
-
resolve,
|
|
803
|
-
BaseAdapter,
|
|
804
|
-
VERBS,
|
|
805
|
-
TARGETS,
|
|
806
|
-
FLAVOURS,
|
|
807
|
-
palette,
|
|
808
|
-
readOwnPackage,
|
|
809
|
-
readOwnVersion,
|
|
810
|
-
readProjectPackageJson,
|
|
811
|
-
readNxProjectName,
|
|
812
|
-
resolveBin,
|
|
813
|
-
registerAdapters,
|
|
814
|
-
describeFramework,
|
|
815
|
-
detectFramework,
|
|
816
|
-
dispatch
|
|
817
|
-
};
|
|
818
|
-
//# sourceMappingURL=chunk-SK6E5EM4.js.map
|