@hublo/sentinel 0.1.0-alpha.0
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/LICENSE +21 -0
- package/README.md +276 -0
- package/dist/bin/sentinel.d.ts +1 -0
- package/dist/bin/sentinel.js +289 -0
- package/dist/bin/sentinel.js.map +1 -0
- package/dist/chunk-D6QBEHMF.js +609 -0
- package/dist/chunk-D6QBEHMF.js.map +1 -0
- package/dist/index.d.ts +243 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/tsconfig/nest.json +26 -0
- package/dist/tsconfig/node.json +23 -0
- package/dist/tsconfig/react.json +27 -0
- package/package.json +72 -0
|
@@ -0,0 +1,609 @@
|
|
|
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 resolve(target, flavour, runner) {
|
|
16
|
+
const forTarget = adapters.filter((a) => a.target === target);
|
|
17
|
+
if (forTarget.length === 0) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`No adapter registered for target "${target}" yet (it ships in a later ticket).`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
const candidates = flavour ? forTarget.filter((a) => a.appliesTo(flavour)) : forTarget;
|
|
23
|
+
if (candidates.length === 0) {
|
|
24
|
+
throw new Error(`No adapter for target "${target}" handles flavour "${flavour}".`);
|
|
25
|
+
}
|
|
26
|
+
const wanted = runner ?? defaultRunner[target];
|
|
27
|
+
const available = candidates.map((a) => a.runner).join(", ");
|
|
28
|
+
if (!wanted) {
|
|
29
|
+
const [first, ...rest] = candidates;
|
|
30
|
+
if (first && rest.length === 0) return first;
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Multiple runners for target "${target}" (${available}); pass --runner or set a default.`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const matching = candidates.filter((a) => a.runner === wanted);
|
|
36
|
+
if (matching.length === 0) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`No runner "${wanted}" for target "${target}" (flavour "${flavour}"). Available: ${available}.`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (matching.length > 1) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Ambiguous: ${matching.length} adapters claim target "${target}", runner "${wanted}", flavour "${flavour}".`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return matching[0];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/core/base-adapter.ts
|
|
50
|
+
var BaseAdapter = class {
|
|
51
|
+
inspect(_ctx) {
|
|
52
|
+
throw new Error(`${this.runner}: --inspect not implemented yet`);
|
|
53
|
+
}
|
|
54
|
+
report(_ctx) {
|
|
55
|
+
throw new Error(`${this.runner}: --report not implemented yet`);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// src/shared/package-json.ts
|
|
60
|
+
import { existsSync, readFileSync } from "fs";
|
|
61
|
+
import { dirname, join } from "path";
|
|
62
|
+
import { fileURLToPath } from "url";
|
|
63
|
+
function readOwnVersion() {
|
|
64
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
65
|
+
for (; ; ) {
|
|
66
|
+
const pkgPath = join(dir, "package.json");
|
|
67
|
+
if (existsSync(pkgPath)) {
|
|
68
|
+
try {
|
|
69
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
70
|
+
if (typeof pkg.version === "string") return pkg.version;
|
|
71
|
+
} catch {
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const parent = dirname(dir);
|
|
75
|
+
if (parent === dir) return "0.0.0";
|
|
76
|
+
dir = parent;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function readProjectPackageJson(dir) {
|
|
80
|
+
const path = join(dir, "package.json");
|
|
81
|
+
if (!existsSync(path)) return {};
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
84
|
+
} catch {
|
|
85
|
+
process.stderr.write(`sentinel: could not parse ${path}; ignoring for detection.
|
|
86
|
+
`);
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function readNxProjectName(dir) {
|
|
91
|
+
const path = join(dir, "project.json");
|
|
92
|
+
if (!existsSync(path)) return void 0;
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
95
|
+
return typeof parsed.name === "string" ? parsed.name : void 0;
|
|
96
|
+
} catch {
|
|
97
|
+
return void 0;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/roles/typescript/adapters/tsc/tsc.adapter.ts
|
|
102
|
+
import { spawnSync } from "child_process";
|
|
103
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
|
|
104
|
+
import { basename, join as join4 } from "path";
|
|
105
|
+
|
|
106
|
+
// src/shared/jsonc.ts
|
|
107
|
+
import { parse, printParseErrorCode } from "jsonc-parser";
|
|
108
|
+
function parseJsonc(text, source = "config") {
|
|
109
|
+
const errors = [];
|
|
110
|
+
const value = parse(text, errors, { allowTrailingComma: true });
|
|
111
|
+
if (errors.length > 0) {
|
|
112
|
+
const details = errors.map((error) => printParseErrorCode(error.error)).join(", ");
|
|
113
|
+
throw new Error(`${source}: malformed JSONC (${details}).`);
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/shared/resolve-bin.ts
|
|
119
|
+
import { existsSync as existsSync2 } from "fs";
|
|
120
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
121
|
+
function resolveBin(fromDir, name) {
|
|
122
|
+
let dir = fromDir;
|
|
123
|
+
for (; ; ) {
|
|
124
|
+
const candidate = join2(dir, "node_modules", ".bin", name);
|
|
125
|
+
if (existsSync2(candidate)) return candidate;
|
|
126
|
+
const parent = dirname2(dir);
|
|
127
|
+
if (parent === dir) return void 0;
|
|
128
|
+
dir = parent;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// src/roles/typescript/config-policy.ts
|
|
133
|
+
var PERMITTED_COMPILER_OPTIONS = [
|
|
134
|
+
"paths",
|
|
135
|
+
"baseUrl",
|
|
136
|
+
"rootDir",
|
|
137
|
+
"outDir",
|
|
138
|
+
"tsBuildInfoFile"
|
|
139
|
+
];
|
|
140
|
+
function presetOwnedKeys(compilerOptions) {
|
|
141
|
+
if (!compilerOptions) return [];
|
|
142
|
+
return Object.keys(compilerOptions).filter((key) => !PERMITTED_COMPILER_OPTIONS.includes(key));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/roles/typescript/presets.ts
|
|
146
|
+
var SHIPPED_FLAVOURS = ["react", "nest", "node"];
|
|
147
|
+
function hasShippedPreset(flavour) {
|
|
148
|
+
return SHIPPED_FLAVOURS.includes(flavour);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/roles/typescript/resolve-tsconfig-target.ts
|
|
152
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
153
|
+
import { join as join3 } from "path";
|
|
154
|
+
var TARGET_EXTENDS_MARKERS = ["tsconfig.base.json", "@hublo/sentinel/tsconfig/"];
|
|
155
|
+
var CANDIDATES = ["tsconfig.app.json", "tsconfig.json"];
|
|
156
|
+
function readExtends(absolutePath) {
|
|
157
|
+
let parsed;
|
|
158
|
+
try {
|
|
159
|
+
parsed = parseJsonc(readFileSync2(absolutePath, "utf8"), absolutePath);
|
|
160
|
+
} catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
if (typeof parsed.extends === "string") return [parsed.extends];
|
|
164
|
+
if (Array.isArray(parsed.extends)) {
|
|
165
|
+
return parsed.extends.filter((entry) => typeof entry === "string");
|
|
166
|
+
}
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
function resolveTsconfigTarget(moduleDir) {
|
|
170
|
+
let existing;
|
|
171
|
+
for (const candidate of CANDIDATES) {
|
|
172
|
+
const absolutePath = join3(moduleDir, candidate);
|
|
173
|
+
if (!existsSync3(absolutePath)) continue;
|
|
174
|
+
existing ??= candidate;
|
|
175
|
+
const extendsValues = readExtends(absolutePath);
|
|
176
|
+
const extendsBase = extendsValues.some(
|
|
177
|
+
(value) => TARGET_EXTENDS_MARKERS.some((marker) => value.includes(marker))
|
|
178
|
+
);
|
|
179
|
+
if (extendsBase) {
|
|
180
|
+
return { path: candidate, reason: "extends-base" };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (existing) return { path: existing, reason: "other-chain" };
|
|
184
|
+
return { path: "tsconfig.json", reason: "none" };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/roles/typescript/adapters/tsc/tsc.adapter.ts
|
|
188
|
+
var TYPECHECK_SCRIPT = { typecheck: "sentinel --run --typescript" };
|
|
189
|
+
var TscAdapter = class extends BaseAdapter {
|
|
190
|
+
target = "typescript";
|
|
191
|
+
runner = "tsc";
|
|
192
|
+
/**
|
|
193
|
+
* The tsc adapter drives type-checking for any flavour: `--run`/`--report`/
|
|
194
|
+
* `--inspect` just execute tsc against the module's existing config, which is
|
|
195
|
+
* meaningful regardless of flavour. `--update` is the exception, it only WRITES a
|
|
196
|
+
* preset for flavours that ship one (gated inside `plan`), so svelte is not
|
|
197
|
+
* clobbered with a non-existent preset.
|
|
198
|
+
*/
|
|
199
|
+
appliesTo(_flavour) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Plan `--update`: make the module extend the sentinel preset with a THIN,
|
|
204
|
+
* conformant stub, and route type-checking through the CLI. The engine applies
|
|
205
|
+
* the ops; ensuring the `@hublo/sentinel` dependency is an adoption step
|
|
206
|
+
* (`pnpm add`), not a file write.
|
|
207
|
+
*
|
|
208
|
+
* Per resolved case:
|
|
209
|
+
* - extends-base: set `extends` + strip preset-owned `compilerOptions` (drift),
|
|
210
|
+
* keeping the project's own paths/include (the allowlist).
|
|
211
|
+
* - none: create a fresh thin `tsconfig.json`.
|
|
212
|
+
* - other-chain (svelte): skip, its config extends a different base.
|
|
213
|
+
*/
|
|
214
|
+
plan(context) {
|
|
215
|
+
if (!hasShippedPreset(context.flavour)) {
|
|
216
|
+
return {
|
|
217
|
+
operations: [],
|
|
218
|
+
notes: [`skipped: no TypeScript preset for flavour "${context.flavour}" yet`]
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
const target = resolveTsconfigTarget(context.cwd);
|
|
222
|
+
const preset = `@hublo/sentinel/tsconfig/${context.flavour}`;
|
|
223
|
+
const addScript = this.typecheckScriptOperation(context.cwd);
|
|
224
|
+
if (target.reason === "other-chain") {
|
|
225
|
+
return {
|
|
226
|
+
operations: [],
|
|
227
|
+
notes: [`skipped: ${target.path} extends a non-base config; handled separately`]
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (target.reason === "none") {
|
|
231
|
+
const contents = JSON.stringify({ extends: preset, include: ["src"] }, null, 2) + "\n";
|
|
232
|
+
return {
|
|
233
|
+
operations: [{ kind: "write", path: target.path, contents }, addScript],
|
|
234
|
+
notes: [`created ${target.path} (no tsconfig found)`]
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const existing = parseJsonc(
|
|
238
|
+
readFileSync3(join4(context.cwd, target.path), "utf8"),
|
|
239
|
+
target.path
|
|
240
|
+
);
|
|
241
|
+
const drift = presetOwnedKeys(existing.compilerOptions);
|
|
242
|
+
const operations = [
|
|
243
|
+
{ kind: "merge-json", path: target.path, value: { extends: preset } }
|
|
244
|
+
];
|
|
245
|
+
const notes = [];
|
|
246
|
+
if (drift.length > 0) {
|
|
247
|
+
operations.push({
|
|
248
|
+
kind: "remove-json-keys",
|
|
249
|
+
path: target.path,
|
|
250
|
+
keys: drift.map((key) => ["compilerOptions", key])
|
|
251
|
+
});
|
|
252
|
+
notes.push(`stripped preset-owned compilerOptions: ${drift.join(", ")}`);
|
|
253
|
+
}
|
|
254
|
+
operations.push(addScript);
|
|
255
|
+
return { operations, notes };
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* The op that routes type-checking through the CLI. If the module already has a
|
|
259
|
+
* `package.json`, merge the script in and leave the rest untouched. If it does
|
|
260
|
+
* NOT (common for nx apps/services that carry only a `project.json`), scaffold a
|
|
261
|
+
* minimal, workspace-valid one, its nx name + `private: true`, so pnpm accepts it
|
|
262
|
+
* and it can then receive the `@hublo/sentinel` devDep (added via `pnpm add` at
|
|
263
|
+
* adoption, never written here, so the lockfile stays authoritative).
|
|
264
|
+
*/
|
|
265
|
+
typecheckScriptOperation(cwd) {
|
|
266
|
+
if (existsSync4(join4(cwd, "package.json"))) {
|
|
267
|
+
return { kind: "merge-json", path: "package.json", value: { scripts: TYPECHECK_SCRIPT } };
|
|
268
|
+
}
|
|
269
|
+
const name = readNxProjectName(cwd) ?? basename(cwd);
|
|
270
|
+
return {
|
|
271
|
+
kind: "merge-json",
|
|
272
|
+
path: "package.json",
|
|
273
|
+
value: { name, private: true, scripts: TYPECHECK_SCRIPT }
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Run `tsc --noEmit` on the module's type-check config (the one that extends the
|
|
278
|
+
* base/preset), using the module's own tsc. No tsconfig to check is a pass.
|
|
279
|
+
*/
|
|
280
|
+
async run(ctx) {
|
|
281
|
+
const target = resolveTsconfigTarget(ctx.cwd);
|
|
282
|
+
if (target.reason === "none") {
|
|
283
|
+
process.stderr.write("sentinel typescript(tsc): no tsconfig to check\n");
|
|
284
|
+
return { ok: true, code: 0 };
|
|
285
|
+
}
|
|
286
|
+
const tsc = resolveBin(ctx.cwd, "tsc") ?? "tsc";
|
|
287
|
+
const result = spawnSync(tsc, ["--noEmit", "--project", target.path], {
|
|
288
|
+
cwd: ctx.cwd,
|
|
289
|
+
stdio: "inherit"
|
|
290
|
+
});
|
|
291
|
+
if (result.error) {
|
|
292
|
+
process.stderr.write(
|
|
293
|
+
`sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
|
|
294
|
+
`
|
|
295
|
+
);
|
|
296
|
+
return { ok: false, code: 1 };
|
|
297
|
+
}
|
|
298
|
+
const code = result.status ?? 1;
|
|
299
|
+
return { ok: code === 0, code };
|
|
300
|
+
}
|
|
301
|
+
/** The module's resolved TypeScript config: which preset, which file, and how. */
|
|
302
|
+
async inspect(ctx) {
|
|
303
|
+
const target = resolveTsconfigTarget(ctx.cwd);
|
|
304
|
+
return {
|
|
305
|
+
module: ctx.module,
|
|
306
|
+
target: "typescript",
|
|
307
|
+
flavour: ctx.flavour,
|
|
308
|
+
configFile: target.path,
|
|
309
|
+
configState: target.reason,
|
|
310
|
+
preset: target.reason === "none" ? null : `@hublo/sentinel/tsconfig/${ctx.flavour}`
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Report conformance for the module: run tsc and count total type errors and the
|
|
315
|
+
* implicit-`any` subset (TS7006), the signal that drives the noImplicitAny
|
|
316
|
+
* migration. No tsconfig is a clean, empty report.
|
|
317
|
+
*/
|
|
318
|
+
async report(ctx) {
|
|
319
|
+
const target = resolveTsconfigTarget(ctx.cwd);
|
|
320
|
+
if (target.reason === "none") {
|
|
321
|
+
return { ok: true, code: 0, metrics: { errors: 0, implicitAny: 0 } };
|
|
322
|
+
}
|
|
323
|
+
const tsc = resolveBin(ctx.cwd, "tsc") ?? "tsc";
|
|
324
|
+
const result = spawnSync(tsc, ["--noEmit", "--project", target.path], {
|
|
325
|
+
cwd: ctx.cwd,
|
|
326
|
+
encoding: "utf8"
|
|
327
|
+
});
|
|
328
|
+
if (result.error) {
|
|
329
|
+
process.stderr.write(
|
|
330
|
+
`sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
|
|
331
|
+
`
|
|
332
|
+
);
|
|
333
|
+
return { ok: false, code: 1, metrics: { error: "tsc not available" } };
|
|
334
|
+
}
|
|
335
|
+
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
336
|
+
const errors = (output.match(/error TS\d+/g) ?? []).length;
|
|
337
|
+
const implicitAny = (output.match(/error TS7006/g) ?? []).length;
|
|
338
|
+
return { ok: errors === 0, code: result.status ?? 0, metrics: { errors, implicitAny } };
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// src/roles/typescript/register.ts
|
|
343
|
+
function registerTypescript() {
|
|
344
|
+
register(new TscAdapter());
|
|
345
|
+
setDefaultRunner("typescript", "tsc");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/adapters.ts
|
|
349
|
+
function registerAdapters() {
|
|
350
|
+
registerTypescript();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// src/core/detect-framework.ts
|
|
354
|
+
var FRAMEWORK_SIGNALS = [
|
|
355
|
+
{ flavour: "nest", dependency: "@nestjs/core" },
|
|
356
|
+
{ flavour: "svelte", dependency: "svelte" },
|
|
357
|
+
{ flavour: "react", dependency: "react" }
|
|
358
|
+
];
|
|
359
|
+
function detectFramework(packageJson) {
|
|
360
|
+
const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies };
|
|
361
|
+
for (const { flavour, dependency } of FRAMEWORK_SIGNALS) {
|
|
362
|
+
if (dependency in dependencies) return flavour;
|
|
363
|
+
}
|
|
364
|
+
return "node";
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// src/shared/text.ts
|
|
368
|
+
function ensureLines(current, lines) {
|
|
369
|
+
const present = new Set(current.split("\n").map((line) => line.trim()));
|
|
370
|
+
const missing = lines.filter((line) => !present.has(line.trim()));
|
|
371
|
+
if (missing.length === 0) return current;
|
|
372
|
+
const prefix = current.length === 0 || current.endsWith("\n") ? current : current + "\n";
|
|
373
|
+
return prefix + missing.join("\n") + "\n";
|
|
374
|
+
}
|
|
375
|
+
function toLines(text) {
|
|
376
|
+
if (text.length === 0) return [];
|
|
377
|
+
return text.replace(/\n$/, "").split("\n");
|
|
378
|
+
}
|
|
379
|
+
function diffLines(before, after) {
|
|
380
|
+
const from = toLines(before);
|
|
381
|
+
const to = toLines(after);
|
|
382
|
+
const lcs = Array.from(
|
|
383
|
+
{ length: from.length + 1 },
|
|
384
|
+
() => new Array(to.length + 1).fill(0)
|
|
385
|
+
);
|
|
386
|
+
const cell = (i2, j2) => lcs[i2]?.[j2] ?? 0;
|
|
387
|
+
for (let i2 = from.length - 1; i2 >= 0; i2--) {
|
|
388
|
+
const row = lcs[i2];
|
|
389
|
+
if (!row) continue;
|
|
390
|
+
for (let j2 = to.length - 1; j2 >= 0; j2--) {
|
|
391
|
+
row[j2] = from[i2] === to[j2] ? cell(i2 + 1, j2 + 1) + 1 : Math.max(cell(i2 + 1, j2), cell(i2, j2 + 1));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const out = [];
|
|
395
|
+
let i = 0;
|
|
396
|
+
let j = 0;
|
|
397
|
+
while (i < from.length && j < to.length) {
|
|
398
|
+
if (from[i] === to[j]) {
|
|
399
|
+
out.push(` ${from[i] ?? ""}`);
|
|
400
|
+
i++;
|
|
401
|
+
j++;
|
|
402
|
+
} else if (cell(i + 1, j) >= cell(i, j + 1)) {
|
|
403
|
+
out.push(`- ${from[i] ?? ""}`);
|
|
404
|
+
i++;
|
|
405
|
+
} else {
|
|
406
|
+
out.push(`+ ${to[j] ?? ""}`);
|
|
407
|
+
j++;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
while (i < from.length) out.push(`- ${from[i++] ?? ""}`);
|
|
411
|
+
while (j < to.length) out.push(`+ ${to[j++] ?? ""}`);
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// src/core/apply-plan.ts
|
|
416
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4, renameSync, writeFileSync } from "fs";
|
|
417
|
+
import { resolve as resolve2, sep } from "path";
|
|
418
|
+
import { applyEdits, modify } from "jsonc-parser";
|
|
419
|
+
|
|
420
|
+
// src/shared/deep-merge.ts
|
|
421
|
+
function isPlainObject(value) {
|
|
422
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/core/apply-plan.ts
|
|
426
|
+
function resolveWithinRoot(cwd, relativePath) {
|
|
427
|
+
const root = resolve2(cwd);
|
|
428
|
+
const absolutePath = resolve2(root, relativePath);
|
|
429
|
+
if (absolutePath !== root && !absolutePath.startsWith(root + sep)) {
|
|
430
|
+
throw new Error(`Refusing to write outside the module root: "${relativePath}".`);
|
|
431
|
+
}
|
|
432
|
+
return absolutePath;
|
|
433
|
+
}
|
|
434
|
+
function readIfExists(absolutePath) {
|
|
435
|
+
return existsSync5(absolutePath) ? readFileSync4(absolutePath, "utf8") : void 0;
|
|
436
|
+
}
|
|
437
|
+
function* leaves(value, prefix = []) {
|
|
438
|
+
for (const [key, keyValue] of Object.entries(value)) {
|
|
439
|
+
const path = [...prefix, key];
|
|
440
|
+
if (isPlainObject(keyValue)) yield* leaves(keyValue, path);
|
|
441
|
+
else yield [path, keyValue];
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function mergeJsonc(current, value) {
|
|
445
|
+
let text = current.trim().length > 0 ? current : "{}\n";
|
|
446
|
+
for (const [path, leaf] of leaves(value)) {
|
|
447
|
+
const edits = modify(text, path, leaf, {
|
|
448
|
+
formattingOptions: { insertSpaces: true, tabSize: 2 }
|
|
449
|
+
});
|
|
450
|
+
text = applyEdits(text, edits);
|
|
451
|
+
}
|
|
452
|
+
return text.endsWith("\n") ? text : text + "\n";
|
|
453
|
+
}
|
|
454
|
+
function removeJsoncKeys(current, keys) {
|
|
455
|
+
let text = current.trim().length > 0 ? current : "{}\n";
|
|
456
|
+
for (const path of keys) {
|
|
457
|
+
const edits = modify(text, path, void 0, {
|
|
458
|
+
formattingOptions: { insertSpaces: true, tabSize: 2 }
|
|
459
|
+
});
|
|
460
|
+
text = applyEdits(text, edits);
|
|
461
|
+
}
|
|
462
|
+
return text.endsWith("\n") ? text : text + "\n";
|
|
463
|
+
}
|
|
464
|
+
function applyOperationTo(current, operation) {
|
|
465
|
+
switch (operation.kind) {
|
|
466
|
+
case "write":
|
|
467
|
+
return operation.contents;
|
|
468
|
+
case "merge-json":
|
|
469
|
+
return mergeJsonc(current, operation.value);
|
|
470
|
+
case "ensure-lines":
|
|
471
|
+
return ensureLines(current, operation.lines);
|
|
472
|
+
case "remove-json-keys":
|
|
473
|
+
return removeJsoncKeys(current, operation.keys);
|
|
474
|
+
default: {
|
|
475
|
+
const unreachable = operation;
|
|
476
|
+
throw new Error(`Unknown file operation: ${JSON.stringify(unreachable)}`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function preparePlan(cwd, plan) {
|
|
481
|
+
const prepared = /* @__PURE__ */ new Map();
|
|
482
|
+
for (const operation of plan.operations) {
|
|
483
|
+
const absolutePath = resolveWithinRoot(cwd, operation.path);
|
|
484
|
+
const existing = prepared.get(operation.path);
|
|
485
|
+
const before = existing?.before ?? readIfExists(absolutePath) ?? "";
|
|
486
|
+
const current = existing?.after ?? before;
|
|
487
|
+
prepared.set(operation.path, {
|
|
488
|
+
path: operation.path,
|
|
489
|
+
absolutePath,
|
|
490
|
+
before,
|
|
491
|
+
after: applyOperationTo(current, operation)
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
return [...prepared.values()];
|
|
495
|
+
}
|
|
496
|
+
function writeFileAtomic(absolutePath, contents) {
|
|
497
|
+
const tempPath = `${absolutePath}.sentinel-${process.pid}.tmp`;
|
|
498
|
+
writeFileSync(tempPath, contents);
|
|
499
|
+
renameSync(tempPath, absolutePath);
|
|
500
|
+
}
|
|
501
|
+
function applyPlan(cwd, plan) {
|
|
502
|
+
const prepared = preparePlan(cwd, plan);
|
|
503
|
+
for (const file of prepared) writeFileAtomic(file.absolutePath, file.after);
|
|
504
|
+
return prepared.map((file) => file.path);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// src/core/dispatch.ts
|
|
508
|
+
function resolveFlavour(opts) {
|
|
509
|
+
return opts.flavour ?? detectFramework(readProjectPackageJson(opts.cwd));
|
|
510
|
+
}
|
|
511
|
+
function previewPlan(opts, plan) {
|
|
512
|
+
const changed = preparePlan(opts.cwd, plan).filter((file) => file.before !== file.after);
|
|
513
|
+
if (opts.json) {
|
|
514
|
+
process.stdout.write(
|
|
515
|
+
JSON.stringify(
|
|
516
|
+
{
|
|
517
|
+
dryRun: true,
|
|
518
|
+
notes: plan.notes ?? [],
|
|
519
|
+
files: changed.map(({ path, before, after }) => ({
|
|
520
|
+
path,
|
|
521
|
+
action: before.length === 0 ? "create" : "update",
|
|
522
|
+
before,
|
|
523
|
+
after
|
|
524
|
+
}))
|
|
525
|
+
},
|
|
526
|
+
null,
|
|
527
|
+
2
|
|
528
|
+
) + "\n"
|
|
529
|
+
);
|
|
530
|
+
return 0;
|
|
531
|
+
}
|
|
532
|
+
process.stderr.write(" dry run: no files written\n");
|
|
533
|
+
for (const note of plan.notes ?? []) process.stderr.write(` ${note}
|
|
534
|
+
`);
|
|
535
|
+
if (changed.length === 0) {
|
|
536
|
+
process.stderr.write(" nothing to change\n");
|
|
537
|
+
return 0;
|
|
538
|
+
}
|
|
539
|
+
for (const { path, before, after } of changed) {
|
|
540
|
+
const action = before.length === 0 ? "create" : "update";
|
|
541
|
+
process.stdout.write(`
|
|
542
|
+
${action} ${path}
|
|
543
|
+
`);
|
|
544
|
+
for (const line of diffLines(before, after)) process.stdout.write(` ${line}
|
|
545
|
+
`);
|
|
546
|
+
}
|
|
547
|
+
return 0;
|
|
548
|
+
}
|
|
549
|
+
async function dispatch(opts) {
|
|
550
|
+
const adapter = resolve(opts.target, opts.flavour, opts.runner);
|
|
551
|
+
const flavour = resolveFlavour(opts);
|
|
552
|
+
const ctx = {
|
|
553
|
+
module: opts.module,
|
|
554
|
+
cwd: opts.cwd,
|
|
555
|
+
flavour,
|
|
556
|
+
ci: opts.ci,
|
|
557
|
+
fix: opts.fix
|
|
558
|
+
};
|
|
559
|
+
switch (opts.verb) {
|
|
560
|
+
case "run": {
|
|
561
|
+
const res = await adapter.run(ctx);
|
|
562
|
+
return res.code;
|
|
563
|
+
}
|
|
564
|
+
case "inspect": {
|
|
565
|
+
const config = await adapter.inspect(ctx);
|
|
566
|
+
process.stdout.write(JSON.stringify(config, null, 2) + "\n");
|
|
567
|
+
return 0;
|
|
568
|
+
}
|
|
569
|
+
case "update": {
|
|
570
|
+
const context = { cwd: opts.cwd, flavour };
|
|
571
|
+
const plan = await adapter.plan(context);
|
|
572
|
+
if (opts.dryRun) {
|
|
573
|
+
return previewPlan(opts, plan);
|
|
574
|
+
}
|
|
575
|
+
const written = applyPlan(opts.cwd, plan);
|
|
576
|
+
for (const path of written) process.stderr.write(` wrote ${path}
|
|
577
|
+
`);
|
|
578
|
+
for (const note of plan.notes ?? []) process.stderr.write(` ${note}
|
|
579
|
+
`);
|
|
580
|
+
return 0;
|
|
581
|
+
}
|
|
582
|
+
case "report": {
|
|
583
|
+
const res = await adapter.report(ctx);
|
|
584
|
+
if (res.metrics) {
|
|
585
|
+
process.stdout.write(JSON.stringify(res.metrics, null, 2) + "\n");
|
|
586
|
+
}
|
|
587
|
+
return res.code;
|
|
588
|
+
}
|
|
589
|
+
default: {
|
|
590
|
+
const unreachable = opts.verb;
|
|
591
|
+
throw new Error(`Unknown verb: ${String(unreachable)}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export {
|
|
597
|
+
register,
|
|
598
|
+
setDefaultRunner,
|
|
599
|
+
all,
|
|
600
|
+
resolve,
|
|
601
|
+
BaseAdapter,
|
|
602
|
+
readOwnVersion,
|
|
603
|
+
readProjectPackageJson,
|
|
604
|
+
resolveBin,
|
|
605
|
+
registerAdapters,
|
|
606
|
+
detectFramework,
|
|
607
|
+
dispatch
|
|
608
|
+
};
|
|
609
|
+
//# sourceMappingURL=chunk-D6QBEHMF.js.map
|