@hublo/sentinel 1.1.5 → 1.2.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.
@@ -1,5 +1,20 @@
1
+ import {
2
+ REACT_APP_DEFAULTS,
3
+ isPlainObject
4
+ } from "./chunk-XSCDJZDY.js";
5
+
1
6
  // src/core/registry.ts
2
7
  var adapters = [];
8
+ var PresetUnsupportedError = class extends Error {
9
+ constructor(target, preset) {
10
+ super(`No adapter for target "${target}" handles preset "${preset}".`);
11
+ this.target = target;
12
+ this.preset = preset;
13
+ this.name = "PresetUnsupportedError";
14
+ }
15
+ target;
16
+ preset;
17
+ };
3
18
  var defaultRunner = {
4
19
  // Filled in by tool branches, e.g. lint: 'eslint', typescript: 'tsc'.
5
20
  };
@@ -12,6 +27,14 @@ function setDefaultRunner(target, runner) {
12
27
  function all() {
13
28
  return adapters;
14
29
  }
30
+ function declaredPresetFor(target, cwd) {
31
+ for (const adapter of adapters) {
32
+ if (adapter.target !== target) continue;
33
+ const declared = adapter.declaredPreset?.(cwd);
34
+ if (declared !== void 0) return declared;
35
+ }
36
+ return void 0;
37
+ }
15
38
  function availableTargets() {
16
39
  return [...new Set(adapters.map((a) => a.target))];
17
40
  }
@@ -24,7 +47,7 @@ function resolve(target, preset, runner) {
24
47
  }
25
48
  const candidates = preset ? forTarget.filter((a) => a.appliesTo(preset)) : forTarget;
26
49
  if (candidates.length === 0) {
27
- throw new Error(`No adapter for target "${target}" handles preset "${preset}".`);
50
+ throw new PresetUnsupportedError(target, preset);
28
51
  }
29
52
  const wanted = runner ?? defaultRunner[target];
30
53
  const available = candidates.map((a) => a.runner).join(", ");
@@ -131,21 +154,79 @@ var TARGETS = [
131
154
  "format",
132
155
  "typescript",
133
156
  "build",
157
+ "dev",
134
158
  "test",
135
159
  "static-analysis",
136
160
  "runtime-analysis",
137
161
  "arch"
138
162
  ];
163
+ var LONG_RUNNING_TARGETS = ["dev"];
164
+ var SWEEPABLE_TARGETS = TARGETS.filter(
165
+ (target) => !LONG_RUNNING_TARGETS.includes(target)
166
+ );
139
167
  var PRESET_NAMES = ["react", "nest", "svelte", "node", "tools"];
140
168
 
141
- // src/roles/format/adapters/oxfmt/oxfmt.adapter.ts
169
+ // src/roles/build/adapters/vite/vite-dev.adapter.ts
142
170
  import { spawnSync } from "child_process";
143
- import { existsSync as existsSync10, readFileSync as readFileSync9 } from "fs";
144
- import { join as join12 } from "path";
171
+
172
+ // src/core/config/tool-args.ts
173
+ import { existsSync as existsSync2 } from "fs";
174
+ import { isAbsolute, resolve as resolve2 } from "path";
175
+ function splitToolArgs(cwd, toolArgs = [], { valueFlags = [] } = {}) {
176
+ const takesValue = new Set(valueFlags);
177
+ const options = [];
178
+ const paths = [];
179
+ let previousTakesValue = false;
180
+ for (const arg of toolArgs) {
181
+ const looksLikeOption = arg.startsWith("-");
182
+ const target = isAbsolute(arg) ? arg : resolve2(cwd, arg);
183
+ if (!previousTakesValue && !looksLikeOption && existsSync2(target)) paths.push(arg);
184
+ else options.push(arg);
185
+ previousTakesValue = !arg.includes("=") && takesValue.has(arg);
186
+ }
187
+ return { options, paths };
188
+ }
189
+
190
+ // src/shared/resolve-bin.ts
191
+ import { existsSync as existsSync3 } from "fs";
192
+ import { createRequire } from "module";
193
+ import { delimiter, dirname as dirname2, join as join2 } from "path";
194
+ var require2 = createRequire(import.meta.url);
195
+ function resolveBin(fromDir, name) {
196
+ let dir = fromDir;
197
+ for (; ; ) {
198
+ const candidate = join2(dir, "node_modules", ".bin", name);
199
+ if (existsSync3(candidate)) return candidate;
200
+ const parent = dirname2(dir);
201
+ if (parent === dir) return void 0;
202
+ dir = parent;
203
+ }
204
+ }
205
+ function binFromOwnInstall(packageName, binName) {
206
+ try {
207
+ const manifest = require2.resolve(`${packageName}/package.json`);
208
+ const bin = require2(manifest).bin;
209
+ const relative3 = typeof bin === "string" ? bin : bin?.[binName];
210
+ if (!relative3) return void 0;
211
+ const executable = join2(dirname2(manifest), relative3);
212
+ return existsSync3(executable) ? executable : void 0;
213
+ } catch {
214
+ return void 0;
215
+ }
216
+ }
217
+ function binSearchPath(cwd) {
218
+ return [`${cwd}${delimiter}node_modules/.bin (and parents)`, "sentinel's own install"].join(
219
+ " then "
220
+ );
221
+ }
222
+
223
+ // src/roles/build/plan.ts
224
+ import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
225
+ import { join as join7 } from "path";
145
226
 
146
227
  // src/core/config/existing-command.ts
147
228
  import { readFileSync as readFileSync3 } from "fs";
148
- import { join as join3 } from "path";
229
+ import { join as join4 } from "path";
149
230
 
150
231
  // src/shared/jsonc.ts
151
232
  import { parse, printParseErrorCode } from "jsonc-parser";
@@ -160,11 +241,11 @@ function parseJsonc(text, source = "config") {
160
241
  }
161
242
 
162
243
  // src/core/config/manifest.ts
163
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
164
- import { basename, join as join2 } from "path";
244
+ import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
245
+ import { basename, join as join3 } from "path";
165
246
  function moduleScripts(cwd) {
166
247
  try {
167
- const pkg = JSON.parse(readFileSync2(join2(cwd, "package.json"), "utf8"));
248
+ const pkg = JSON.parse(readFileSync2(join3(cwd, "package.json"), "utf8"));
168
249
  return pkg.scripts ?? {};
169
250
  } catch {
170
251
  return {};
@@ -172,7 +253,7 @@ function moduleScripts(cwd) {
172
253
  }
173
254
  function moduleName(cwd) {
174
255
  try {
175
- const pkg = JSON.parse(readFileSync2(join2(cwd, "package.json"), "utf8"));
256
+ const pkg = JSON.parse(readFileSync2(join3(cwd, "package.json"), "utf8"));
176
257
  return pkg.name;
177
258
  } catch {
178
259
  return void 0;
@@ -192,54 +273,675 @@ function manifestOperation(cwd, scripts) {
192
273
  scripts,
193
274
  devDependencies: { [own.name]: isSelfAdoption(cwd) ? "link:." : own.version }
194
275
  };
195
- if (existsSync2(join2(cwd, "package.json"))) {
276
+ if (existsSync4(join3(cwd, "package.json"))) {
196
277
  return { kind: "merge-json", path: "package.json", value };
197
278
  }
198
- return {
199
- kind: "merge-json",
200
- path: "package.json",
201
- value: { name: readNxProjectName(cwd) ?? basename(cwd), private: true, ...value }
202
- };
203
- }
204
-
205
- // src/core/config/existing-command.ts
206
- function nxTargetCommand(cwd, target) {
207
- let project;
208
- try {
209
- project = parseJsonc(
210
- readFileSync3(join3(cwd, "project.json"), "utf8"),
211
- "project.json"
212
- );
213
- } catch {
214
- return void 0;
279
+ return {
280
+ kind: "merge-json",
281
+ path: "package.json",
282
+ value: { name: readNxProjectName(cwd) ?? basename(cwd), private: true, ...value }
283
+ };
284
+ }
285
+
286
+ // src/core/config/existing-command.ts
287
+ function nxTargetCommand(cwd, target) {
288
+ let project;
289
+ try {
290
+ project = parseJsonc(
291
+ readFileSync3(join4(cwd, "project.json"), "utf8"),
292
+ "project.json"
293
+ );
294
+ } catch {
295
+ return void 0;
296
+ }
297
+ const entry = project.targets?.[target];
298
+ if (!entry || entry.executor !== "nx:run-commands") return void 0;
299
+ const { command, commands, cwd: targetCwd } = entry.options ?? {};
300
+ const ranInModule = typeof targetCwd === "string" && targetCwd.trim() !== "";
301
+ if (typeof command === "string" && command.trim() !== "") {
302
+ return { command: command.trim(), ranInModule };
303
+ }
304
+ if (Array.isArray(commands)) {
305
+ const parts = commands.filter((c) => typeof c === "string" && c.trim() !== "");
306
+ if (parts.length > 0) return { command: parts.map((c) => c.trim()).join(" && "), ranInModule };
307
+ }
308
+ return void 0;
309
+ }
310
+ function rootedForScript(command) {
311
+ return command.replace(/(^|&&\s*)pnpm exec /g, "$1pnpm --workspace-root exec ");
312
+ }
313
+ function existingCommand(cwd, target) {
314
+ const script = moduleScripts(cwd)[target];
315
+ if (script !== void 0 && script.trim() !== "") return script;
316
+ const fromTarget = nxTargetCommand(cwd, target);
317
+ if (fromTarget === void 0) return void 0;
318
+ return fromTarget.ranInModule ? fromTarget.command : rootedForScript(fromTarget.command);
319
+ }
320
+
321
+ // src/core/config/nx-target.ts
322
+ import { existsSync as existsSync5 } from "fs";
323
+ import { join as join5 } from "path";
324
+ function nxTargetOperations(options) {
325
+ const { cwd, targets } = options;
326
+ const names = Object.keys(targets);
327
+ if (names.length === 0) return [];
328
+ const operations = [];
329
+ if (existsSync5(join5(cwd, "project.json"))) {
330
+ operations.push({
331
+ kind: "remove-json-keys",
332
+ path: "project.json",
333
+ keys: names.map((name) => ["targets", name])
334
+ });
335
+ }
336
+ operations.push({
337
+ kind: "merge-json",
338
+ path: "package.json",
339
+ value: { nx: { targets } }
340
+ });
341
+ return operations;
342
+ }
343
+
344
+ // src/core/config/tool-script.ts
345
+ var SEGMENT_SEPARATOR = /(\s*(?:&&|\|\||;|&|\|)\s*)/;
346
+ function isSeparatorAt(index) {
347
+ return index % 2 === 1;
348
+ }
349
+ function invokes(segment, binary) {
350
+ return new RegExp(String.raw`(^|[\s/])${binary}(\s|$)`).test(segment.trim());
351
+ }
352
+ function isSentinelSegment(segment, roleFlag) {
353
+ const runsSentinel = invokes(segment, "sentinel") || segment.includes("bin/sentinel.js");
354
+ return runsSentinel && new RegExp(String.raw`(^|\s)${roleFlag}(\s|$)`).test(segment);
355
+ }
356
+ function composeToolScript(existing, options) {
357
+ const { command } = options;
358
+ if (!existing || existing.trim() === "") return command;
359
+ const { roleFlag } = options;
360
+ const isReplaceable = (segment) => options.replaces(segment) || roleFlag !== void 0 && isSentinelSegment(segment, roleFlag);
361
+ const parts = existing.split(SEGMENT_SEPARATOR);
362
+ const commands = parts.filter((_, index) => !isSeparatorAt(index));
363
+ if (!commands.some(isReplaceable)) return existing;
364
+ let replacedOnce = false;
365
+ const rebuilt = parts.map((part, index) => {
366
+ if (isSeparatorAt(index) || !isReplaceable(part)) return part;
367
+ if (replacedOnce) return null;
368
+ replacedOnce = true;
369
+ return options.keepFix && /(^|\s)--fix(\s|$)/.test(part) ? `${command} --fix` : command;
370
+ });
371
+ const kept = [];
372
+ for (let index = 0; index < rebuilt.length; index += 1) {
373
+ const part = rebuilt[index];
374
+ if (part === null) {
375
+ if (kept.length > 0) kept.pop();
376
+ continue;
377
+ }
378
+ kept.push(part);
379
+ }
380
+ return kept.join("").trim();
381
+ }
382
+ function keepsOtherCommands(script, sentinelCommand) {
383
+ return script.split(SEGMENT_SEPARATOR).filter((_, index) => !isSeparatorAt(index)).some((segment) => segment.trim() !== "" && segment.trim() !== sentinelCommand);
384
+ }
385
+
386
+ // src/roles/build/config-policy.ts
387
+ var BUILD_CONFIG_FILES = [
388
+ "vite.config.ts",
389
+ "vite.config.mts",
390
+ "vite.config.js",
391
+ "vite.config.mjs"
392
+ ];
393
+ var BUILD_PRESET_SPECIFIER = "@hublo/sentinel/build/react";
394
+ var BUILD_SCRIPT_NAME = "build";
395
+ var SENTINEL_BUILD_COMMAND = "sentinel --run --build";
396
+ var DEV_SCRIPT_NAME = "serve";
397
+ var SENTINEL_DEV_COMMAND = "sentinel --run --dev";
398
+ function buildTarget() {
399
+ return {
400
+ [BUILD_SCRIPT_NAME]: {
401
+ cache: true,
402
+ inputs: ["default", "^default", `{projectRoot}/${BUILD_CONFIG_FILES[0]}`]
403
+ }
404
+ };
405
+ }
406
+ var SENTINEL_OWNED_BUILD_PACKAGES = [
407
+ "vite",
408
+ "@vitejs/plugin-react",
409
+ "@tailwindcss/vite",
410
+ "vite-plugin-svgr",
411
+ "nitro"
412
+ ];
413
+
414
+ // src/roles/build/dev-script.ts
415
+ var CHAIN = " -- ";
416
+ var VITE_TOKEN = /(^|\s|\/)vite(\s|$)/;
417
+ var DEV_SUBCOMMANDS = /* @__PURE__ */ new Set(["dev", "serve"]);
418
+ function appOptions(rest) {
419
+ const tokens = (rest ?? "").trim().split(/\s+/).filter(Boolean);
420
+ const kept = tokens.filter((token, at) => !(at === 0 && DEV_SUBCOMMANDS.has(token)));
421
+ return kept.join(" ");
422
+ }
423
+ function composeDevScript(existing, command) {
424
+ if (existing === void 0 || existing.trim() === "") return command;
425
+ const chunks = existing.trim().split(CHAIN);
426
+ const at = chunks.map((chunk) => VITE_TOKEN.test(chunk)).lastIndexOf(true);
427
+ if (at === -1) return existing;
428
+ const rest = (chunks[at] ?? "").replace(/^.*?(^|\s|\/)vite(\s|$)/, "");
429
+ const options = appOptions(rest);
430
+ chunks[at] = options === "" ? command : `${command} -- ${options}`;
431
+ return chunks.join(CHAIN);
432
+ }
433
+
434
+ // src/roles/build/read-adoption.ts
435
+ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "fs";
436
+ import { join as join6 } from "path";
437
+ var NOT_ADOPTED = (configFile, unreadable = null) => ({
438
+ configFile,
439
+ preset: null,
440
+ adopted: false,
441
+ conformant: false,
442
+ drift: [],
443
+ ownDeclarations: [],
444
+ unreadable
445
+ });
446
+ function buildConfigFile(cwd) {
447
+ return BUILD_CONFIG_FILES.find((name) => existsSync6(join6(cwd, name)));
448
+ }
449
+ function importsPreset(source) {
450
+ const specifier = BUILD_PRESET_SPECIFIER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
451
+ return new RegExp(`(?:from|import)\\s*\\(?\\s*['"\`]${specifier}['"\`]`).test(source);
452
+ }
453
+ var REACT_PLUGIN_SPECIFIERS = ["@vitejs/plugin-react", "@tanstack/react-start"];
454
+ function declaredBuildPreset(cwd) {
455
+ const configFile = buildConfigFile(cwd);
456
+ if (!configFile) return void 0;
457
+ try {
458
+ const source = readFileSync4(join6(cwd, configFile), "utf8");
459
+ return REACT_PLUGIN_SPECIFIERS.some((specifier) => source.includes(specifier)) ? "react" : void 0;
460
+ } catch {
461
+ return void 0;
462
+ }
463
+ }
464
+ function readBuildAdoption(cwd) {
465
+ const configFile = buildConfigFile(cwd);
466
+ if (!configFile) return NOT_ADOPTED(null);
467
+ let source;
468
+ try {
469
+ source = readFileSync4(join6(cwd, configFile), "utf8");
470
+ } catch (error) {
471
+ return NOT_ADOPTED(configFile, error instanceof Error ? error.message : String(error));
472
+ }
473
+ if (!importsPreset(source)) return NOT_ADOPTED(configFile);
474
+ const manifest = readProjectPackageJson(cwd);
475
+ const declared = { ...manifest.dependencies, ...manifest.devDependencies };
476
+ const ownDeclarations = SENTINEL_OWNED_BUILD_PACKAGES.filter((name) => name in declared).map(
477
+ (name) => ({ name, version: declared[name] })
478
+ );
479
+ return {
480
+ configFile,
481
+ preset: "react",
482
+ adopted: true,
483
+ conformant: ownDeclarations.length === 0,
484
+ drift: ownDeclarations.map(
485
+ ({ name, version }) => `declares ${name}@${version} of its own; sentinel owns it, and two copies in one build give the plugins a different Vite than the one running them`
486
+ ),
487
+ ownDeclarations,
488
+ unreadable: null
489
+ };
490
+ }
491
+
492
+ // src/roles/build/plan.ts
493
+ function scaffold() {
494
+ return `import { defineConfig, reactApp } from '${BUILD_PRESET_SPECIFIER}'
495
+ import { tanstackStart } from '@tanstack/react-start/plugin/vite'
496
+ import { tanstackRouter } from '@tanstack/router-plugin/vite'
497
+ import path from 'node:path'
498
+
499
+ /*
500
+ * Composition comes from sentinel: how the env is loaded, which plugins run under test and
501
+ * which under a real build, the order they run in. The values below are this app's own.
502
+ *
503
+ * TanStack is passed IN rather than imported by sentinel, because the app codes against it
504
+ * directly. \`alias\` is passed through verbatim and never read, so nothing in it can be lost.
505
+ * Anything sentinel does not set goes in \`overrides\`, which merges over the preset last.
506
+ */
507
+ export default defineConfig(({ mode }) =>
508
+ reactApp({
509
+ root: __dirname,
510
+ mode,
511
+ base: '/',
512
+ port: 3000,
513
+ // Explicit, NOT port + 1: the three apps in this repo disagree, one of them goes down.
514
+ hmrPort: 3001,
515
+ router: {
516
+ routesDirectory: path.resolve(__dirname, 'src/routes'),
517
+ generatedRouteTree: path.resolve(__dirname, 'src/routeTree.gen.ts'),
518
+ },
519
+ tanstack: { start: tanstackStart, router: tanstackRouter },
520
+ alias: [],
521
+ }),
522
+ )
523
+ `;
524
+ }
525
+ function ownedBuildDependencies(cwd) {
526
+ const manifest = readProjectPackageJson(cwd);
527
+ const keys = [];
528
+ for (const section of ["dependencies", "devDependencies"]) {
529
+ const declared = manifest[section];
530
+ if (!declared) continue;
531
+ for (const name of SENTINEL_OWNED_BUILD_PACKAGES) {
532
+ if (name in declared) keys.push([section, name]);
533
+ }
534
+ }
535
+ return keys;
536
+ }
537
+ function invokesVite(segment) {
538
+ return /(^|\s|\/)vite(\s|$)/.test(segment.trim());
539
+ }
540
+ function buildScripts(cwd) {
541
+ const own = selfCommand(cwd, "--run --build");
542
+ const scripts = {
543
+ [BUILD_SCRIPT_NAME]: composeToolScript(existingCommand(cwd, BUILD_SCRIPT_NAME), {
544
+ command: own ?? SENTINEL_BUILD_COMMAND,
545
+ replaces: invokesVite,
546
+ // Only meaningful for the self case: it lets a re-init correct a sentinel invocation
547
+ // whose FORM is wrong, without matching another role's script.
548
+ roleFlag: own === void 0 ? void 0 : "--build"
549
+ })
550
+ };
551
+ const existingDev = existingCommand(cwd, DEV_SCRIPT_NAME);
552
+ if (existingDev !== void 0) {
553
+ scripts[DEV_SCRIPT_NAME] = composeDevScript(
554
+ existingDev,
555
+ selfCommand(cwd, "--run --dev") ?? SENTINEL_DEV_COMMAND
556
+ );
557
+ }
558
+ return scripts;
559
+ }
560
+ function plan(context) {
561
+ if (declaredBuildPreset(context.cwd) === void 0 && buildConfigFile(context.cwd) !== void 0) {
562
+ return {
563
+ operations: [],
564
+ skipped: `this module has a Vite config, but it does not build a React app (no @vitejs/plugin-react or @tanstack/react-start in it). The build role ships a React preset only; the two SvelteKit configs in this repo share nothing with it.`
565
+ };
566
+ }
567
+ const notes = [];
568
+ const operations = [];
569
+ const configFile = buildConfigFile(context.cwd);
570
+ if (configFile === void 0) {
571
+ operations.push({ kind: "write", path: BUILD_CONFIG_FILES[0], contents: scaffold() });
572
+ notes.push(
573
+ `wrote ${BUILD_CONFIG_FILES[0]} as a starting point. Fill in this app's own values (base, ports, routes); sentinel owns the composition, not the data.`
574
+ );
575
+ } else if (!readsPreset(context.cwd, configFile)) {
576
+ notes.push(
577
+ `${configFile} is this app's own, so it was left alone. To adopt: import \`reactApp\` from \`${BUILD_PRESET_SPECIFIER}\` and pass it this app's values, keeping \`alias\` and your own plugins verbatim. \`sentinel --inspect --build\` reports what the resolved config departs from and what it adds, before and after.`
578
+ );
579
+ }
580
+ operations.push(manifestOperation(context.cwd, buildScripts(context.cwd)));
581
+ operations.push(...nxTargetOperations({ cwd: context.cwd, targets: buildTarget() }));
582
+ const owned = ownedBuildDependencies(context.cwd);
583
+ if (owned.length > 0) {
584
+ operations.push({ kind: "remove-json-keys", path: "package.json", keys: owned });
585
+ notes.push(
586
+ `removed ${owned.map(([, name]) => name).join(", ")} from this module: sentinel owns them now, and two copies of Vite in one build give the plugins a different Vite than the one running them.`
587
+ );
588
+ }
589
+ return { operations, notes };
590
+ }
591
+ function readsPreset(cwd, configFile) {
592
+ if (!existsSync7(join7(cwd, configFile))) return false;
593
+ try {
594
+ return readFileSync5(join7(cwd, configFile), "utf8").includes(BUILD_PRESET_SPECIFIER);
595
+ } catch {
596
+ return false;
597
+ }
598
+ }
599
+
600
+ // src/roles/build/resolve-vite.ts
601
+ function resolveVite(cwd) {
602
+ return binFromOwnInstall("vite", "vite") ?? resolveBin(cwd, "vite");
603
+ }
604
+ function viteOrigin(cwd) {
605
+ if (binFromOwnInstall("vite", "vite")) return "sentinel";
606
+ return resolveBin(cwd, "vite") ? "module" : "none";
607
+ }
608
+
609
+ // src/roles/build/adapters/vite/vite-dev.adapter.ts
610
+ var ViteDevAdapter = class extends BaseAdapter {
611
+ target = "dev";
612
+ runner = "vite";
613
+ appliesTo(preset) {
614
+ return preset === "react";
615
+ }
616
+ declaredPreset(cwd) {
617
+ return declaredBuildPreset(cwd);
618
+ }
619
+ /**
620
+ * The same plan as `--build`, deliberately.
621
+ *
622
+ * `build` and `serve` are one app's two calls into one toolchain, so adopting one without
623
+ * the other leaves the module half-migrated in a way nothing reports. The operations are
624
+ * idempotent, so `--init --build` and `--init --dev` are interchangeable rather than
625
+ * additive.
626
+ */
627
+ plan(context) {
628
+ return plan(context);
629
+ }
630
+ /**
631
+ * Start the dev server. It does not return until stopped, so there is no verdict to report
632
+ * beyond the exit code the developer's own Ctrl-C produces.
633
+ *
634
+ * No `prebuild` here, unlike `--run --build`: the artefact this repo generates before a build
635
+ * is produced by the watcher that WRAPS this command (`run-with-runtime-artifact-watch`), and
636
+ * running it again would race the watcher that is about to own the file.
637
+ */
638
+ async run(ctx) {
639
+ if (!readBuildAdoption(ctx.cwd).adopted) {
640
+ process.stderr.write(
641
+ `sentinel dev(vite): no sentinel preset in this module's Vite config; run \`sentinel --init --build\` to adopt.
642
+ `
643
+ );
644
+ return { ok: true, code: 0 };
645
+ }
646
+ const vite = resolveVite(ctx.cwd);
647
+ if (!vite) {
648
+ process.stderr.write(
649
+ `sentinel dev(vite): could not find the vite binary (looked in ${binSearchPath(ctx.cwd)}). Run \`pnpm install\` in the module.
650
+ `
651
+ );
652
+ return { ok: false, code: 1 };
653
+ }
654
+ if (viteOrigin(ctx.cwd) === "module") {
655
+ process.stderr.write(
656
+ `sentinel dev(vite): serving with the MODULE's vite, not sentinel's. An adopted config gets its plugins from sentinel, and two copies of Vite in one process fail in ways that never mention a version. Remove vite from this module's package.json.
657
+ `
658
+ );
659
+ }
660
+ const { options, paths } = splitToolArgs(ctx.cwd, ctx.toolArgs, { valueFlags: [] });
661
+ const result = spawnSync(vite, [...options, ...paths], { cwd: ctx.cwd, stdio: "inherit" });
662
+ if (result.error) {
663
+ process.stderr.write(`sentinel dev(vite): could not run vite (${result.error.message})
664
+ `);
665
+ return { ok: false, code: 1 };
666
+ }
667
+ const code = result.status ?? 0;
668
+ return { ok: code === 0, code };
669
+ }
670
+ async status(ctx) {
671
+ const { adopted, preset, conformant, drift, unreadable } = readBuildAdoption(ctx.cwd);
672
+ return { adopted, preset, conformant, drift, unreadable };
673
+ }
674
+ };
675
+
676
+ // src/roles/build/adapters/vite/vite.adapter.ts
677
+ import { spawnSync as spawnSync3 } from "child_process";
678
+
679
+ // src/roles/build/owned-paths.ts
680
+ var PRESET_OWNED_KEYS = [
681
+ "base",
682
+ "root",
683
+ "define",
684
+ "server",
685
+ "build",
686
+ "resolve",
687
+ "plugins"
688
+ ];
689
+ function describeDepartures(config, defaults) {
690
+ const departures = [];
691
+ const build = asRecord(config.build);
692
+ const target = build?.target;
693
+ if (typeof target === "string" && target !== defaults.target) {
694
+ departures.push({
695
+ rule: "build.target",
696
+ reason: `${target}, where the convention is ${defaults.target}`
697
+ });
698
+ }
699
+ const server = asRecord(config.server);
700
+ const hosts = server?.allowedHosts;
701
+ if (Array.isArray(hosts) && !sameStrings(hosts, defaults.allowedHosts)) {
702
+ departures.push({
703
+ rule: "server.allowedHosts",
704
+ reason: `${JSON.stringify(hosts)}, where the convention is ${JSON.stringify(defaults.allowedHosts)}`
705
+ });
706
+ }
707
+ return departures;
708
+ }
709
+ function appAdditions(config) {
710
+ const owned = new Set(PRESET_OWNED_KEYS);
711
+ return Object.keys(config).filter((key) => !owned.has(key)).sort();
712
+ }
713
+ function asRecord(value) {
714
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
715
+ }
716
+ function sameStrings(a, b) {
717
+ return a.length === b.length && a.every((value, at) => value === b[at]);
718
+ }
719
+
720
+ // src/roles/build/prerequisite.ts
721
+ import { spawnSync as spawnSync2 } from "child_process";
722
+ var PREBUILD_SCRIPT_NAME = `pre${BUILD_SCRIPT_NAME}`;
723
+ function insideBuildScript(env = process.env) {
724
+ return env.npm_lifecycle_event === BUILD_SCRIPT_NAME;
725
+ }
726
+ function runPrerequisite(cwd, env = process.env) {
727
+ const scripts = moduleScripts(cwd);
728
+ const script = scripts[PREBUILD_SCRIPT_NAME];
729
+ if (script === void 0 || script.trim() === "") return { kind: "none" };
730
+ if (insideBuildScript(env)) return { kind: "already-run" };
731
+ const result = spawnSync2("pnpm", ["run", PREBUILD_SCRIPT_NAME], { cwd, stdio: "inherit" });
732
+ if (result.error) {
733
+ return { kind: "failed", code: 1, reason: result.error.message };
734
+ }
735
+ const code = result.status ?? 1;
736
+ if (code !== 0) {
737
+ return {
738
+ kind: "failed",
739
+ code,
740
+ reason: `\`pnpm run ${PREBUILD_SCRIPT_NAME}\` exited ${code}`
741
+ };
742
+ }
743
+ return { kind: "ran" };
744
+ }
745
+
746
+ // src/roles/build/resolve-config.ts
747
+ async function resolveBuildConfig(cwd) {
748
+ try {
749
+ const { loadConfigFromFile } = await import("vite");
750
+ const loaded = await loadConfigFromFile(
751
+ { command: "build", mode: "production" },
752
+ void 0,
753
+ cwd,
754
+ "silent"
755
+ );
756
+ if (!loaded) return { kind: "failed", reason: "Vite found no config file in this module" };
757
+ return { kind: "loaded", config: loaded.config, from: loaded.path };
758
+ } catch (error) {
759
+ return { kind: "failed", reason: error instanceof Error ? error.message : String(error) };
760
+ }
761
+ }
762
+
763
+ // src/roles/build/adapters/vite/vite.adapter.ts
764
+ var VITE_VALUE_FLAGS = ["--config", "-c", "--mode", "-m", "--outDir", "--logLevel", "--base"];
765
+ var ViteAdapter = class extends BaseAdapter {
766
+ target = "build";
767
+ runner = "vite";
768
+ /**
769
+ * React only. The two SvelteKit configs in this repo are 34 and 21 lines and share nothing
770
+ * with the React three; claiming them would mean a preset that fits neither.
771
+ */
772
+ appliesTo(preset) {
773
+ return preset === "react";
774
+ }
775
+ /**
776
+ * `react`, read from the module's Vite config rather than from its dependencies.
777
+ *
778
+ * Without this the role is unreachable in practice: the toolchain is declared at the ROOT,
779
+ * so every front app detects as `node` and `appliesTo` filters the adapter out — including
780
+ * during `sentinel --inspect` with no target, which never passes `--preset`.
781
+ */
782
+ declaredPreset(cwd) {
783
+ return declaredBuildPreset(cwd);
784
+ }
785
+ plan(context) {
786
+ return plan(context);
787
+ }
788
+ /**
789
+ * Build the module.
790
+ *
791
+ * A module that has not adopted is reported and passes, the same stance every other role
792
+ * takes: `--run` with no named target sweeps every wired target across the workspace, so
793
+ * failing here would exit non-zero on every module that has not migrated, which is most of
794
+ * them.
795
+ */
796
+ async run(ctx) {
797
+ const adoption = readBuildAdoption(ctx.cwd);
798
+ if (!adoption.adopted) {
799
+ process.stderr.write(
800
+ `sentinel build(vite): no sentinel preset in this module's Vite config; run \`sentinel --init --build\` to adopt.
801
+ `
802
+ );
803
+ return { ok: true, code: 0 };
804
+ }
805
+ const prerequisite = runPrerequisite(ctx.cwd);
806
+ if (prerequisite.kind === "failed") {
807
+ process.stderr.write(
808
+ `sentinel build(vite): the ${PREBUILD_SCRIPT_NAME} step failed, so the build was not started: ${prerequisite.reason}
809
+ `
810
+ );
811
+ return { ok: false, code: prerequisite.code };
812
+ }
813
+ const vite = resolveVite(ctx.cwd);
814
+ if (!vite) {
815
+ process.stderr.write(
816
+ `sentinel build(vite): could not find the vite binary (looked in ${binSearchPath(ctx.cwd)}). Run \`pnpm install\` in the module.
817
+ `
818
+ );
819
+ return { ok: false, code: 1 };
820
+ }
821
+ if (viteOrigin(ctx.cwd) === "module") {
822
+ process.stderr.write(
823
+ `sentinel build(vite): building with the MODULE's vite, not sentinel's. An adopted config gets its plugins from sentinel, and two copies of Vite in one build fail in ways that never mention a version. Remove vite from this module's package.json.
824
+ `
825
+ );
826
+ }
827
+ const { options, paths } = splitToolArgs(ctx.cwd, ctx.toolArgs, {
828
+ valueFlags: VITE_VALUE_FLAGS
829
+ });
830
+ const result = spawnSync3(vite, ["build", ...options, ...paths], {
831
+ cwd: ctx.cwd,
832
+ stdio: "inherit"
833
+ });
834
+ if (result.error) {
835
+ process.stderr.write(`sentinel build(vite): could not run vite (${result.error.message})
836
+ `);
837
+ return { ok: false, code: 1 };
838
+ }
839
+ const code = result.status ?? 1;
840
+ return { ok: code === 0, code };
841
+ }
842
+ /**
843
+ * What this module builds with, without building it.
844
+ *
845
+ * `--inspect` reads the committed config for every role, and the moment `--build` was wired
846
+ * it joined the sweep — so leaving this to throw would have broken `sentinel --inspect` on
847
+ * every React module, for a role that had only just arrived. Cheap and honest is the bar.
848
+ *
849
+ * `vite` is the interesting field and the one nothing else reports: an adopted config takes
850
+ * its plugins from sentinel, so a module answering `module` here is one build away from the
851
+ * two-copies failure, and this is where that is visible before it happens.
852
+ *
853
+ * The resolved config comes from Vite's own loader rather than from parsing the file. See
854
+ * `resolve-config` for why that is the only honest answer here, and what it costs.
855
+ */
856
+ async inspect(ctx) {
857
+ const adoption = readBuildAdoption(ctx.cwd);
858
+ const base = {
859
+ runner: "vite",
860
+ configFile: adoption.configFile,
861
+ ...adoption.unreadable ? { unreadable: adoption.unreadable } : {},
862
+ vite: viteOrigin(ctx.cwd),
863
+ // Rendered as a labelled block by the shared renderer, the same shape the lint role's
864
+ // parked rules and the format role's overrides already use.
865
+ overrides: adoption.ownDeclarations.map(({ name, version }) => ({
866
+ rule: name,
867
+ reason: `declared by this module at ${version}, where sentinel owns it`
868
+ })),
869
+ prerequisite: moduleScripts(ctx.cwd)[PREBUILD_SCRIPT_NAME] ?? null
870
+ };
871
+ if (adoption.configFile === null) return base;
872
+ const resolved = await resolveBuildConfig(ctx.cwd);
873
+ if (resolved.kind === "failed") {
874
+ return { ...base, configError: resolved.reason };
875
+ }
876
+ const config = resolved.config;
877
+ return {
878
+ ...base,
879
+ // What the app departs from, and what it adds, are different facts. A DEPARTURE is a
880
+ // disagreement with an opinion the preset holds; an ADDITION is the app needing
881
+ // something the preset never claimed, which is the preset working rather than being
882
+ // worked around.
883
+ departures: describeDepartures(config, REACT_APP_DEFAULTS),
884
+ additions: appAdditions(config),
885
+ resolved: {
886
+ base: config.base ?? null,
887
+ target: config.build?.target ?? null,
888
+ sourcemap: config.build?.sourcemap ?? null,
889
+ port: config.server?.port ?? null,
890
+ // The one number nothing else reports and that no formula predicts: career goes DOWN
891
+ // to 9998 where the others go up, which is why it is data rather than `port + 1`.
892
+ hmrPort: config.server?.hmr?.port ?? null,
893
+ // Counted, not listed. It is 56-67% of every config today, and printing 296 entries
894
+ // would bury everything above it. The count is what tells you whether it moved.
895
+ aliases: Array.isArray(config.resolve?.alias) ? config.resolve.alias.length : 0,
896
+ plugins: Array.isArray(config.plugins) ? config.plugins.flat(9).length : 0
897
+ }
898
+ };
215
899
  }
216
- const entry = project.targets?.[target];
217
- if (!entry || entry.executor !== "nx:run-commands") return void 0;
218
- const { command, commands, cwd: targetCwd } = entry.options ?? {};
219
- const ranInModule = typeof targetCwd === "string" && targetCwd.trim() !== "";
220
- if (typeof command === "string" && command.trim() !== "") {
221
- return { command: command.trim(), ranInModule };
900
+ /**
901
+ * Adoption as data, WITHOUT building.
902
+ *
903
+ * Deliberately not "run the build and attach metrics". `--report` is what a migration
904
+ * dashboard calls across the whole workspace, and a bundler is the one tool here where doing
905
+ * the real work costs minutes per module rather than seconds. A report that nobody can
906
+ * afford to run is a report nobody runs.
907
+ *
908
+ * Bundle size is the metric this will eventually want, and it needs a build to produce. It
909
+ * belongs behind an explicit opt-in rather than in the default sweep, and the ticket parks
910
+ * it for exactly that reason.
911
+ */
912
+ async report(ctx) {
913
+ const adoption = readBuildAdoption(ctx.cwd);
914
+ return {
915
+ ok: true,
916
+ code: 0,
917
+ metrics: {
918
+ vite: viteOrigin(ctx.cwd),
919
+ ownBuildDependencies: adoption.ownDeclarations.length
920
+ }
921
+ };
222
922
  }
223
- if (Array.isArray(commands)) {
224
- const parts = commands.filter((c) => typeof c === "string" && c.trim() !== "");
225
- if (parts.length > 0) return { command: parts.map((c) => c.trim()).join(" && "), ranInModule };
923
+ async status(ctx) {
924
+ const { adopted, preset, conformant, drift, unreadable } = readBuildAdoption(ctx.cwd);
925
+ return { adopted, preset, conformant, drift, unreadable };
226
926
  }
227
- return void 0;
228
- }
229
- function rootedForScript(command) {
230
- return command.replace(/(^|&&\s*)pnpm exec /g, "$1pnpm --workspace-root exec ");
231
- }
232
- function existingCommand(cwd, target) {
233
- const script = moduleScripts(cwd)[target];
234
- if (script !== void 0 && script.trim() !== "") return script;
235
- const fromTarget = nxTargetCommand(cwd, target);
236
- if (fromTarget === void 0) return void 0;
237
- return fromTarget.ranInModule ? fromTarget.command : rootedForScript(fromTarget.command);
927
+ };
928
+
929
+ // src/roles/build/register.ts
930
+ function registerBuild() {
931
+ register(new ViteAdapter());
932
+ setDefaultRunner("build", "vite");
933
+ register(new ViteDevAdapter());
934
+ setDefaultRunner("dev", "vite");
238
935
  }
239
936
 
937
+ // src/roles/format/adapters/oxfmt/oxfmt.adapter.ts
938
+ import { spawnSync as spawnSync4 } from "child_process";
939
+ import { existsSync as existsSync12, readFileSync as readFileSync11 } from "fs";
940
+ import { join as join14 } from "path";
941
+
240
942
  // src/core/config/has-source.ts
241
943
  import { readdirSync } from "fs";
242
- import { extname, join as join4 } from "path";
944
+ import { extname, join as join8 } from "path";
243
945
  var SKIP_DIRECTORIES = /* @__PURE__ */ new Set([
244
946
  "node_modules",
245
947
  "dist",
@@ -290,7 +992,7 @@ function hasSourceFiles(cwd, extensions) {
290
992
  }
291
993
  for (const entry of entries) {
292
994
  if (entry.isDirectory()) {
293
- if (!SKIP_DIRECTORIES.has(entry.name)) queue.push(join4(dir, entry.name));
995
+ if (!SKIP_DIRECTORIES.has(entry.name)) queue.push(join8(dir, entry.name));
294
996
  continue;
295
997
  }
296
998
  if (wanted.has(extname(entry.name))) return true;
@@ -299,54 +1001,13 @@ function hasSourceFiles(cwd, extensions) {
299
1001
  return false;
300
1002
  }
301
1003
 
302
- // src/core/config/nx-target.ts
303
- import { existsSync as existsSync3 } from "fs";
304
- import { join as join5 } from "path";
305
- function nxTargetOperations(options) {
306
- const { cwd, targets } = options;
307
- const names = Object.keys(targets);
308
- if (names.length === 0) return [];
309
- const operations = [];
310
- if (existsSync3(join5(cwd, "project.json"))) {
311
- operations.push({
312
- kind: "remove-json-keys",
313
- path: "project.json",
314
- keys: names.map((name) => ["targets", name])
315
- });
316
- }
317
- operations.push({
318
- kind: "merge-json",
319
- path: "package.json",
320
- value: { nx: { targets } }
321
- });
322
- return operations;
323
- }
324
-
325
- // src/core/config/tool-args.ts
326
- import { existsSync as existsSync4 } from "fs";
327
- import { isAbsolute, resolve as resolve2 } from "path";
328
- function splitToolArgs(cwd, toolArgs = [], { valueFlags = [] } = {}) {
329
- const takesValue = new Set(valueFlags);
330
- const options = [];
331
- const paths = [];
332
- let previousTakesValue = false;
333
- for (const arg of toolArgs) {
334
- const looksLikeOption = arg.startsWith("-");
335
- const target = isAbsolute(arg) ? arg : resolve2(cwd, arg);
336
- if (!previousTakesValue && !looksLikeOption && existsSync4(target)) paths.push(arg);
337
- else options.push(arg);
338
- previousTakesValue = !arg.includes("=") && takesValue.has(arg);
339
- }
340
- return { options, paths };
341
- }
342
-
343
1004
  // src/core/settings.ts
344
1005
  var WORKSPACE_ROOT_MARKER = "nx.json";
345
1006
  var DEFAULT_MAX_DIAGNOSTICS = 100;
346
1007
 
347
1008
  // src/core/workspace-prep.ts
348
- import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync } from "fs";
349
- import { dirname as dirname2, join as join6, relative } from "path";
1009
+ import { existsSync as existsSync8, readFileSync as readFileSync6, writeFileSync } from "fs";
1010
+ import { dirname as dirname3, join as join9, relative } from "path";
350
1011
  var OVERRIDE_KEY = "i18next>typescript";
351
1012
  var NATIVE_TS_ALIAS = "@typescript/native";
352
1013
  var WORKSPACE_YAML = "pnpm-workspace.yaml";
@@ -355,8 +1016,8 @@ var LIST_ITEM = /^(\s*)-\s+(.*?)\s*$/;
355
1016
  function findWorkspaceRoot(startDir) {
356
1017
  let dir = startDir;
357
1018
  for (; ; ) {
358
- if (existsSync5(join6(dir, WORKSPACE_ROOT_MARKER))) return dir;
359
- const parent = dirname2(dir);
1019
+ if (existsSync8(join9(dir, WORKSPACE_ROOT_MARKER))) return dir;
1020
+ const parent = dirname3(dir);
360
1021
  if (parent === dir) return void 0;
361
1022
  dir = parent;
362
1023
  }
@@ -368,9 +1029,9 @@ function declaredNativeTs(pkg) {
368
1029
  return version || void 0;
369
1030
  }
370
1031
  function ensureI18nextSingleton(root, dryRun) {
371
- const pkgPath = join6(root, "package.json");
372
- if (!existsSync5(pkgPath)) return void 0;
373
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
1032
+ const pkgPath = join9(root, "package.json");
1033
+ if (!existsSync8(pkgPath)) return void 0;
1034
+ const pkg = JSON.parse(readFileSync6(pkgPath, "utf8"));
374
1035
  const want = declaredNativeTs(pkg);
375
1036
  if (!want) return void 0;
376
1037
  const have = pkg.pnpm?.overrides?.[OVERRIDE_KEY];
@@ -383,10 +1044,10 @@ function ensureI18nextSingleton(root, dryRun) {
383
1044
  return `pinned ${OVERRIDE_KEY} to ${want} in package.json (i18next singleton)`;
384
1045
  }
385
1046
  function ensureReleaseAgeAllowList(root, dryRun) {
386
- const yamlPath = join6(root, WORKSPACE_YAML);
387
- if (!existsSync5(yamlPath)) return void 0;
1047
+ const yamlPath = join9(root, WORKSPACE_YAML);
1048
+ if (!existsSync8(yamlPath)) return void 0;
388
1049
  const own = readOwnPackage().name;
389
- const lines = readFileSync4(yamlPath, "utf8").split("\n");
1050
+ const lines = readFileSync6(yamlPath, "utf8").split("\n");
390
1051
  const keyIdx = lines.findIndex((line) => line.replace(/\s+$/, "") === `${RELEASE_AGE_KEY}:`);
391
1052
  if (keyIdx === -1) return void 0;
392
1053
  let lastItemIdx = keyIdx;
@@ -421,11 +1082,11 @@ var ROOT_PRETTIER_CONFIGS = [
421
1082
  function ensureFormatterExclusion(root, moduleDir, dryRun) {
422
1083
  const rel = relative(root, moduleDir).replaceAll("\\", "/");
423
1084
  if (rel === "" || rel.startsWith("..")) return void 0;
424
- const ignorePath = join6(root, PRETTIER_IGNORE);
425
- const hasPrettier = existsSync5(ignorePath) || ROOT_PRETTIER_CONFIGS.some((name) => existsSync5(join6(root, name)));
1085
+ const ignorePath = join9(root, PRETTIER_IGNORE);
1086
+ const hasPrettier = existsSync8(ignorePath) || ROOT_PRETTIER_CONFIGS.some((name) => existsSync8(join9(root, name)));
426
1087
  if (!hasPrettier) return void 0;
427
1088
  const pattern = `/${rel}/`;
428
- const existing = existsSync5(ignorePath) ? readFileSync4(ignorePath, "utf8") : "";
1089
+ const existing = existsSync8(ignorePath) ? readFileSync6(ignorePath, "utf8") : "";
429
1090
  const lines = existing.split("\n");
430
1091
  if (lines.some((line) => line.trim().replace(/\/$/, "") === pattern.replace(/\/$/, ""))) {
431
1092
  return void 0;
@@ -458,38 +1119,39 @@ function ensureWorkspacePrep(opts) {
458
1119
  opts.formattedModule === void 0 ? void 0 : ensureFormatterExclusion(opts.root, opts.formattedModule, dryRun)
459
1120
  ].filter((message) => message !== void 0);
460
1121
  }
461
-
462
- // src/shared/resolve-bin.ts
463
- import { existsSync as existsSync6 } from "fs";
464
- import { createRequire } from "module";
465
- import { delimiter, dirname as dirname3, join as join7 } from "path";
466
- var require2 = createRequire(import.meta.url);
467
- function resolveBin(fromDir, name) {
468
- let dir = fromDir;
469
- for (; ; ) {
470
- const candidate = join7(dir, "node_modules", ".bin", name);
471
- if (existsSync6(candidate)) return candidate;
472
- const parent = dirname3(dir);
473
- if (parent === dir) return void 0;
474
- dir = parent;
1122
+ function inspectWorkspacePrep(root) {
1123
+ const entries = [];
1124
+ let pkg = {};
1125
+ const pkgPath = join9(root, "package.json");
1126
+ if (existsSync8(pkgPath)) {
1127
+ try {
1128
+ pkg = JSON.parse(readFileSync6(pkgPath, "utf8"));
1129
+ } catch {
1130
+ pkg = {};
1131
+ }
475
1132
  }
476
- }
477
- function binFromOwnInstall(packageName, binName) {
478
- try {
479
- const manifest = require2.resolve(`${packageName}/package.json`);
480
- const bin = require2(manifest).bin;
481
- const relative3 = typeof bin === "string" ? bin : bin?.[binName];
482
- if (!relative3) return void 0;
483
- const executable = join7(dirname3(manifest), relative3);
484
- return existsSync6(executable) ? executable : void 0;
485
- } catch {
486
- return void 0;
1133
+ const pinned = pkg.pnpm?.overrides?.[OVERRIDE_KEY];
1134
+ if (pinned !== void 0) {
1135
+ const stillForks = declaredNativeTs(pkg);
1136
+ entries.push({
1137
+ rule: `package.json \u2192 pnpm.overrides.${OVERRIDE_KEY}`,
1138
+ reason: stillForks === void 0 ? `pinned to ${pinned}, but the root no longer declares ${NATIVE_TS_ALIAS}, so nothing forks any more. This override is now dead config and can be removed.` : `pinned to ${pinned}, matching the second TypeScript the root declares (${NATIVE_TS_ALIAS}). It re-resolves i18next for EVERY module that depends on it, not only adopted ones. It retires with the second TypeScript.`
1139
+ });
487
1140
  }
488
- }
489
- function binSearchPath(cwd) {
490
- return [`${cwd}${delimiter}node_modules/.bin (and parents)`, "sentinel's own install"].join(
491
- " then "
492
- );
1141
+ const own = readOwnPackage().name;
1142
+ const yamlPath = join9(root, WORKSPACE_YAML);
1143
+ if (existsSync8(yamlPath)) {
1144
+ const yaml = readFileSync6(yamlPath, "utf8");
1145
+ const listed = new RegExp(`^\\s*-\\s*['"]?${own.replace("/", "\\/")}['"]?\\s*$`, "m").test(yaml);
1146
+ if (listed) {
1147
+ const gated = new RegExp(`^\\s*minimumReleaseAge\\s*:`, "m").test(yaml);
1148
+ entries.push({
1149
+ rule: `${WORKSPACE_YAML} \u2192 ${RELEASE_AGE_KEY}: ${own}`,
1150
+ reason: gated ? `${own} is exempt from this workspace's release-age gate, so a freshly published version installs instead of being held. That is a standing exemption from a supply-chain control: the exposure is one bump wide, since the version is pinned and the lockfile carries its integrity, so a re-publish of the same version does not pass. Retire it once a bump can simply wait out the window.` : `${own} is allow-listed, but this workspace no longer sets minimumReleaseAge, so there is no gate to be exempt from. This entry is now dead config and can be removed.`
1151
+ });
1152
+ }
1153
+ }
1154
+ return entries;
493
1155
  }
494
1156
 
495
1157
  // src/roles/format/config-policy.ts
@@ -514,6 +1176,7 @@ var PERMITTED_LOCAL_KEYS = [
514
1176
  "experimentalOperatorPosition"
515
1177
  ];
516
1178
  var ALWAYS_LOCAL_KEYS = ["ignorePatterns", "overrides"];
1179
+ var FORMAT_DEFAULT_IGNORES = ["**/*.toml"];
517
1180
  var PRETTIER_CONFIG_FILES = [
518
1181
  ".prettierrc",
519
1182
  ".prettierrc.json",
@@ -537,48 +1200,6 @@ function formatTargets() {
537
1200
  };
538
1201
  }
539
1202
 
540
- // src/core/config/tool-script.ts
541
- var SEGMENT_SEPARATOR = /(\s*(?:&&|\|\||;|&|\|)\s*)/;
542
- function isSeparatorAt(index) {
543
- return index % 2 === 1;
544
- }
545
- function invokes(segment, binary) {
546
- return new RegExp(String.raw`(^|[\s/])${binary}(\s|$)`).test(segment.trim());
547
- }
548
- function isSentinelSegment(segment, roleFlag) {
549
- const runsSentinel = invokes(segment, "sentinel") || segment.includes("bin/sentinel.js");
550
- return runsSentinel && new RegExp(String.raw`(^|\s)${roleFlag}(\s|$)`).test(segment);
551
- }
552
- function composeToolScript(existing, options) {
553
- const { command } = options;
554
- if (!existing || existing.trim() === "") return command;
555
- const { roleFlag } = options;
556
- const isReplaceable = (segment) => options.replaces(segment) || roleFlag !== void 0 && isSentinelSegment(segment, roleFlag);
557
- const parts = existing.split(SEGMENT_SEPARATOR);
558
- const commands = parts.filter((_, index) => !isSeparatorAt(index));
559
- if (!commands.some(isReplaceable)) return existing;
560
- let replacedOnce = false;
561
- const rebuilt = parts.map((part, index) => {
562
- if (isSeparatorAt(index) || !isReplaceable(part)) return part;
563
- if (replacedOnce) return null;
564
- replacedOnce = true;
565
- return options.keepFix && /(^|\s)--fix(\s|$)/.test(part) ? `${command} --fix` : command;
566
- });
567
- const kept = [];
568
- for (let index = 0; index < rebuilt.length; index += 1) {
569
- const part = rebuilt[index];
570
- if (part === null) {
571
- if (kept.length > 0) kept.pop();
572
- continue;
573
- }
574
- kept.push(part);
575
- }
576
- return kept.join("").trim();
577
- }
578
- function keepsOtherCommands(script, sentinelCommand) {
579
- return script.split(SEGMENT_SEPARATOR).filter((_, index) => !isSeparatorAt(index)).some((segment) => segment.trim() !== "" && segment.trim() !== sentinelCommand);
580
- }
581
-
582
1203
  // src/roles/format/format-script.ts
583
1204
  var SENTINEL_FORMAT_COMMAND = "sentinel --run --format";
584
1205
  function isPrettierSegment(segment) {
@@ -607,8 +1228,8 @@ function writesWhenRewritten(name, command) {
607
1228
  var CHECK_SCRIPT_NAMES = /* @__PURE__ */ new Set(["lint", "format", "check", "test", "typecheck", "verify"]);
608
1229
 
609
1230
  // src/roles/format/inherited-ignores.ts
610
- import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
611
- import { join as join8 } from "path";
1231
+ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
1232
+ import { join as join10 } from "path";
612
1233
  var ROOT_IGNORE_FILE = ".prettierignore";
613
1234
  function isPattern(line) {
614
1235
  const trimmed = line.trim();
@@ -623,11 +1244,11 @@ function toModulePattern(pattern) {
623
1244
  }
624
1245
  function inheritedIgnorePatterns(workspaceRoot) {
625
1246
  if (!workspaceRoot) return [];
626
- const path = join8(workspaceRoot, ROOT_IGNORE_FILE);
627
- if (!existsSync7(path)) return [];
1247
+ const path = join10(workspaceRoot, ROOT_IGNORE_FILE);
1248
+ if (!existsSync9(path)) return [];
628
1249
  let contents;
629
1250
  try {
630
- contents = readFileSync5(path, "utf8");
1251
+ contents = readFileSync7(path, "utf8");
631
1252
  } catch {
632
1253
  return [];
633
1254
  }
@@ -714,13 +1335,13 @@ function formatPresetFor(preset) {
714
1335
  }
715
1336
 
716
1337
  // src/roles/format/prettier-config.ts
717
- import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
718
- import { join as join10 } from "path";
1338
+ import { existsSync as existsSync10, readFileSync as readFileSync9 } from "fs";
1339
+ import { join as join12 } from "path";
719
1340
 
720
1341
  // src/roles/format/resolve-oxfmt.ts
721
- import { readFileSync as readFileSync6 } from "fs";
1342
+ import { readFileSync as readFileSync8 } from "fs";
722
1343
  import { createRequire as createRequire2 } from "module";
723
- import { dirname as dirname4, join as join9 } from "path";
1344
+ import { dirname as dirname4, join as join11 } from "path";
724
1345
  function resolveOxfmt(cwd) {
725
1346
  return resolveBin(cwd, "oxfmt") ?? binFromOwnInstall("oxfmt", "oxfmt");
726
1347
  }
@@ -749,8 +1370,8 @@ function configSchema() {
749
1370
  function readConfigSchema() {
750
1371
  try {
751
1372
  const manifest = createRequire2(import.meta.url).resolve("oxfmt/package.json");
752
- const schemaPath = join9(dirname4(manifest), "configuration_schema.json");
753
- return JSON.parse(readFileSync6(schemaPath, "utf8"));
1373
+ const schemaPath = join11(dirname4(manifest), "configuration_schema.json");
1374
+ return JSON.parse(readFileSync8(schemaPath, "utf8"));
754
1375
  } catch {
755
1376
  return void 0;
756
1377
  }
@@ -781,14 +1402,14 @@ function toOxfmtOverrides(value) {
781
1402
  return { overrides, unresolved };
782
1403
  }
783
1404
  function readPrettierSettings(cwd) {
784
- const file = PRETTIER_CONFIG_FILES.find((name) => existsSync8(join10(cwd, name)));
1405
+ const file = PRETTIER_CONFIG_FILES.find((name) => existsSync10(join12(cwd, name)));
785
1406
  if (!file) return { options: {}, unresolved: [] };
786
1407
  if (/\.(js|cjs|mjs)$/.test(file)) {
787
1408
  return { options: {}, file, unresolved: [`${file} is JavaScript, so it was not executed`] };
788
1409
  }
789
1410
  let parsed;
790
1411
  try {
791
- parsed = parseJsonc(readFileSync7(join10(cwd, file), "utf8"), file);
1412
+ parsed = parseJsonc(readFileSync9(join12(cwd, file), "utf8"), file);
792
1413
  } catch {
793
1414
  return { options: {}, file, unresolved: [`${file} could not be parsed`] };
794
1415
  }
@@ -818,9 +1439,9 @@ function readPrettierSettings(cwd) {
818
1439
  }
819
1440
 
820
1441
  // src/roles/format/read-adoption.ts
821
- import { existsSync as existsSync9, readFileSync as readFileSync8 } from "fs";
822
- import { join as join11 } from "path";
823
- var NOT_ADOPTED = (configFile, unreadable = null) => ({
1442
+ import { existsSync as existsSync11, readFileSync as readFileSync10 } from "fs";
1443
+ import { join as join13 } from "path";
1444
+ var NOT_ADOPTED2 = (configFile, unreadable = null) => ({
824
1445
  configFile,
825
1446
  preset: null,
826
1447
  adopted: false,
@@ -835,19 +1456,19 @@ function sameValue(a, b) {
835
1456
  return JSON.stringify(a) === JSON.stringify(b);
836
1457
  }
837
1458
  function readFormatAdoption(cwd) {
838
- const path = join11(cwd, FORMAT_CONFIG_FILE);
839
- if (!existsSync9(path)) return NOT_ADOPTED(null);
1459
+ const path = join13(cwd, FORMAT_CONFIG_FILE);
1460
+ if (!existsSync11(path)) return NOT_ADOPTED2(null);
840
1461
  let parsed;
841
1462
  try {
842
- parsed = parseJsonc(readFileSync8(path, "utf8"), FORMAT_CONFIG_FILE);
1463
+ parsed = parseJsonc(readFileSync10(path, "utf8"), FORMAT_CONFIG_FILE);
843
1464
  } catch (error) {
844
1465
  const reason = error instanceof Error ? error.message : String(error);
845
- return NOT_ADOPTED(FORMAT_CONFIG_FILE, `${FORMAT_CONFIG_FILE} could not be parsed (${reason})`);
1466
+ return NOT_ADOPTED2(FORMAT_CONFIG_FILE, `${FORMAT_CONFIG_FILE} could not be parsed (${reason})`);
846
1467
  }
847
1468
  const provenance = parsed[PROVENANCE_KEY];
848
- if (!provenance || typeof provenance.preset !== "string") return NOT_ADOPTED(FORMAT_CONFIG_FILE);
1469
+ if (!provenance || typeof provenance.preset !== "string") return NOT_ADOPTED2(FORMAT_CONFIG_FILE);
849
1470
  if (!hasFormatPreset(provenance.preset)) {
850
- return NOT_ADOPTED(
1471
+ return NOT_ADOPTED2(
851
1472
  FORMAT_CONFIG_FILE,
852
1473
  `${FORMAT_CONFIG_FILE} declares the format preset "${provenance.preset}", which sentinel does not ship (shipped: ${FORMAT_PRESETS.join(", ")}). Re-run \`sentinel --init --format\`.`
853
1474
  );
@@ -926,7 +1547,9 @@ var OxfmtAdapter = class extends BaseAdapter {
926
1547
  const localKeys = Object.keys(local).filter((key) => !ALWAYS_LOCAL_KEYS.includes(key)).sort();
927
1548
  const inherited = inheritedIgnorePatterns(findWorkspaceRoot(context.cwd));
928
1549
  const declaredIgnores = Array.isArray(local.ignorePatterns) ? local.ignorePatterns.filter((v) => typeof v === "string") : [];
929
- const ignorePatterns = [.../* @__PURE__ */ new Set([...declaredIgnores, ...inherited])].sort();
1550
+ const ignorePatterns = [
1551
+ .../* @__PURE__ */ new Set([...declaredIgnores, ...inherited, ...FORMAT_DEFAULT_IGNORES])
1552
+ ].sort();
930
1553
  if (ignorePatterns.length > 0) local.ignorePatterns = ignorePatterns;
931
1554
  const unchanged = existing.adopted && existing.conformant;
932
1555
  const version = unchanged && existing.presetVersion ? existing.presetVersion : own.version;
@@ -946,7 +1569,7 @@ var OxfmtAdapter = class extends BaseAdapter {
946
1569
  manifestOperation(context.cwd, this.formatScripts(context.cwd))
947
1570
  ];
948
1571
  const prettierConfigs = PRETTIER_CONFIG_FILES.filter(
949
- (name) => existsSync10(join12(context.cwd, name))
1572
+ (name) => existsSync12(join14(context.cwd, name))
950
1573
  );
951
1574
  for (const name of prettierConfigs) operations.push({ kind: "delete", path: name });
952
1575
  const removableDeps = this.modulePrettierDependencies(context.cwd);
@@ -1005,7 +1628,7 @@ var OxfmtAdapter = class extends BaseAdapter {
1005
1628
  async afterInit(ctx) {
1006
1629
  const oxfmt = resolveOxfmt(ctx.cwd);
1007
1630
  if (!oxfmt) return;
1008
- const pass = spawnSync(oxfmt, ["--write", "."], { cwd: ctx.cwd, encoding: "utf8" });
1631
+ const pass = spawnSync4(oxfmt, ["--write", "."], { cwd: ctx.cwd, encoding: "utf8" });
1009
1632
  process.stderr.write(` ${palette(process.stderr).dim(FORMATTER_DIFFERENCES)}
1010
1633
  `);
1011
1634
  if (pass.status === 0) return;
@@ -1068,7 +1691,7 @@ var OxfmtAdapter = class extends BaseAdapter {
1068
1691
  const { options, paths } = splitToolArgs(ctx.cwd, ctx.toolArgs, {
1069
1692
  valueFlags: OXFMT_VALUE_FLAGS
1070
1693
  });
1071
- const result = spawnSync(oxfmt, [mode, ...options, ...paths.length > 0 ? paths : ["."]], {
1694
+ const result = spawnSync4(oxfmt, [mode, ...options, ...paths.length > 0 ? paths : ["."]], {
1072
1695
  cwd: ctx.cwd,
1073
1696
  stdio: "inherit"
1074
1697
  });
@@ -1146,7 +1769,7 @@ var OxfmtAdapter = class extends BaseAdapter {
1146
1769
  );
1147
1770
  return { ok: false, code: 1, metrics: { ...base, unformatted: null } };
1148
1771
  }
1149
- const result = spawnSync(oxfmt, ["--list-different", "."], { cwd: ctx.cwd, encoding: "utf8" });
1772
+ const result = spawnSync4(oxfmt, ["--list-different", "."], { cwd: ctx.cwd, encoding: "utf8" });
1150
1773
  if (result.error) {
1151
1774
  process.stderr.write(
1152
1775
  `sentinel format(oxfmt): could not run oxfmt (${result.error.message})
@@ -1182,7 +1805,7 @@ var OxfmtAdapter = class extends BaseAdapter {
1182
1805
  modulePrettierDependencies(cwd) {
1183
1806
  const isPrettierPackage = (name) => name === "prettier" || name.startsWith("prettier-plugin-") || name.startsWith("@prettier/");
1184
1807
  try {
1185
- const manifest = JSON.parse(readFileSync9(join12(cwd, "package.json"), "utf8"));
1808
+ const manifest = JSON.parse(readFileSync11(join14(cwd, "package.json"), "utf8"));
1186
1809
  return Object.keys(manifest.devDependencies ?? {}).filter(isPrettierPackage).map((name) => ["devDependencies", name]);
1187
1810
  } catch {
1188
1811
  return [];
@@ -1263,9 +1886,9 @@ function registerFormat() {
1263
1886
  }
1264
1887
 
1265
1888
  // src/roles/lint/adapters/oxlint/oxlint.adapter.ts
1266
- import { spawnSync as spawnSync2 } from "child_process";
1267
- import { existsSync as existsSync16, readFileSync as readFileSync15, rmSync, writeFileSync as writeFileSync2 } from "fs";
1268
- import { join as join19 } from "path";
1889
+ import { spawnSync as spawnSync5 } from "child_process";
1890
+ import { existsSync as existsSync19, readFileSync as readFileSync18, rmSync, writeFileSync as writeFileSync2 } from "fs";
1891
+ import { join as join22 } from "path";
1269
1892
 
1270
1893
  // src/core/config/deferred-rules.ts
1271
1894
  function deferredRuleNames(rules) {
@@ -1275,10 +1898,18 @@ function deferredRuleNames(rules) {
1275
1898
  // src/roles/lint/config-policy.ts
1276
1899
  var LINT_CONFIG_FILE = ".oxlintrc.json";
1277
1900
  var LINT_SCRIPT_NAME = "lint";
1278
- var PRESET_DIR = "./node_modules/@hublo/sentinel/oxlint";
1901
+ var PRESET_DIR = "./node_modules/@hublo/sentinel/lint";
1279
1902
  function presetPath(preset) {
1280
1903
  return `${PRESET_DIR}/${preset}.json`;
1281
1904
  }
1905
+ function isSentinelPreset(entry) {
1906
+ return /@hublo\/sentinel\/(lint|oxlint)\//.test(entry);
1907
+ }
1908
+ function extendsWithPreset(current, variant) {
1909
+ const own = presetPath(variant);
1910
+ const others = current.filter((entry) => entry !== own && !isSentinelPreset(entry));
1911
+ return [own, ...new Set(others)];
1912
+ }
1282
1913
  function presetVariant(preset, cwd) {
1283
1914
  const normalised = cwd.replaceAll("\\", "/");
1284
1915
  return preset === "react" && /(^|\/)libs\//.test(normalised) ? "react-lib" : preset;
@@ -1286,7 +1917,7 @@ function presetVariant(preset, cwd) {
1286
1917
  function presetOfVariant(variant) {
1287
1918
  return variant === "react-lib" ? "react" : variant;
1288
1919
  }
1289
- var SENTINEL_LINT_PRESET = /@hublo\/sentinel\/oxlint\/([a-z-]+)\.json$/;
1920
+ var SENTINEL_LINT_PRESET = /@hublo\/sentinel\/(?:lint|oxlint)\/([a-z-]+)\.json$/;
1290
1921
  function presetNameFromPath(preset) {
1291
1922
  return preset ? SENTINEL_LINT_PRESET.exec(preset)?.[1] ?? void 0 : void 0;
1292
1923
  }
@@ -1298,7 +1929,7 @@ var ESLINT_CONFIG_FILES = [
1298
1929
  "eslint.config.ts"
1299
1930
  ];
1300
1931
  function lintTarget() {
1301
- return { cache: true, inputs: ["default", `{projectRoot}/${LINT_CONFIG_FILE}`] };
1932
+ return { cache: true, inputs: ["default", "^default", `{projectRoot}/${LINT_CONFIG_FILE}`] };
1302
1933
  }
1303
1934
 
1304
1935
  // src/roles/lint/presets/base.json
@@ -1642,8 +2273,8 @@ var nest_default = {
1642
2273
  },
1643
2274
  "typescript/return-await": {
1644
2275
  severity: "warn",
1645
- reason: "replaces the deprecated core `no-return-await`, which both modules set to error but which was never actually reporting: the nx lint target expanded to 2 files of 2888. The `never` option matches what no-return-await meant, so this is like-for-like, but it now runs over every file and finds real violations (9 on bff-admin, 5 on host-admin, measured with --type-aware). Held at warn so adoption does not turn a green module red, and raised to error once they are fixed. Switching to the stricter `in-try-catch`, which typescript-eslint recommends because `return await` inside try IS needed for correct error handling, is a separate decision with its own count.",
1646
- options: ["never"]
2276
+ reason: "replaces the deprecated core `no-return-await`, which both modules set to error but which was never actually reporting: the nx lint target expanded to 2 files of 2888. `in-try-catch` rather than `never`, and the planning adoption is what settled it. `never` forces `return await f()` to become `const x = await f(); return x` even inside a `try`, where the await is REQUIRED for the catch to fire: it produced 10 such edits across 15 modules, every one of them inside a try/catch, and every one of them a rewrite that made the code longer without making it safer. Worse, the obvious reading of the rule is to delete the `await`, which silently stops the catch from running. `in-try-catch` is what typescript-eslint recommends and flags none of those 10. Held at warn so adoption does not turn a green module red, and raised to error once the remaining findings are fixed.",
2277
+ options: ["in-try-catch"]
1647
2278
  },
1648
2279
  "typescript/switch-exhaustiveness-check": [
1649
2280
  "error",
@@ -2034,8 +2665,8 @@ var react_lib_default = {
2034
2665
  },
2035
2666
  "typescript/return-await": {
2036
2667
  severity: "warn",
2037
- reason: "replaces the deprecated core `no-return-await`. The `never` option is what makes it mean the same thing. Held at warn to match the app tier, where running it over every file found real violations the old rule never reported.",
2038
- options: ["never"]
2668
+ reason: "replaces the deprecated core `no-return-await`, which both modules set to error but which was never actually reporting: the nx lint target expanded to 2 files of 2888. `in-try-catch` rather than `never`, and the planning adoption is what settled it. `never` forces `return await f()` to become `const x = await f(); return x` even inside a `try`, where the await is REQUIRED for the catch to fire: it produced 10 such edits across 15 modules, every one of them inside a try/catch, and every one of them a rewrite that made the code longer without making it safer. Worse, the obvious reading of the rule is to delete the `await`, which silently stops the catch from running. `in-try-catch` is what typescript-eslint recommends and flags none of those 10. Held at warn so adoption does not turn a green module red, and raised to error once the remaining findings are fixed.",
2669
+ options: ["in-try-catch"]
2039
2670
  },
2040
2671
  "use-isnan": [
2041
2672
  "error",
@@ -2608,8 +3239,8 @@ var react_default = {
2608
3239
  "typescript/restrict-template-expressions": "error",
2609
3240
  "typescript/return-await": {
2610
3241
  severity: "warn",
2611
- reason: "replaces the deprecated core `no-return-await`, which both modules set to error but which was never actually reporting: the nx lint target expanded to 2 files of 2888. The `never` option matches what no-return-await meant, so this is like-for-like, but it now runs over every file and finds real violations (9 on bff-admin, 5 on host-admin, measured with --type-aware). Held at warn so adoption does not turn a green module red, and raised to error once they are fixed. Switching to the stricter `in-try-catch`, which typescript-eslint recommends because `return await` inside try IS needed for correct error handling, is a separate decision with its own count.",
2612
- options: ["never"]
3242
+ reason: "replaces the deprecated core `no-return-await`, which both modules set to error but which was never actually reporting: the nx lint target expanded to 2 files of 2888. `in-try-catch` rather than `never`, and the planning adoption is what settled it. `never` forces `return await f()` to become `const x = await f(); return x` even inside a `try`, where the await is REQUIRED for the catch to fire: it produced 10 such edits across 15 modules, every one of them inside a try/catch, and every one of them a rewrite that made the code longer without making it safer. Worse, the obvious reading of the rule is to delete the `await`, which silently stops the catch from running. `in-try-catch` is what typescript-eslint recommends and flags none of those 10. Held at warn so adoption does not turn a green module red, and raised to error once the remaining findings are fixed.",
3243
+ options: ["in-try-catch"]
2613
3244
  },
2614
3245
  "typescript/switch-exhaustiveness-check": {
2615
3246
  severity: "warn",
@@ -3070,6 +3701,10 @@ function downgradedRulesFor(preset) {
3070
3701
  return downgradedFor(preset);
3071
3702
  }
3072
3703
 
3704
+ // src/roles/lint/extra-layers.ts
3705
+ import { existsSync as existsSync13, readFileSync as readFileSync12 } from "fs";
3706
+ import { isAbsolute as isAbsolute2, join as join15, resolve as resolve3 } from "path";
3707
+
3073
3708
  // src/roles/lint/module-baseline.ts
3074
3709
  var LINT_BASELINE_FILE = ".oxlintrc.baseline.json";
3075
3710
  var LINT_MEASURE_FILE = ".oxlintrc.measure.json";
@@ -3078,10 +3713,7 @@ function holdsFrom(errorCounts) {
3078
3713
  return [...errorCounts].map(([rule, count]) => ({ rule, count })).sort((left, right) => right.count - left.count || left.rule.localeCompare(right.rule));
3079
3714
  }
3080
3715
  function renderBaseline(holds, version) {
3081
- const entries = holds.map(
3082
- ({ rule, count }) => ` // ${count} violation${count === 1 ? "" : "s"} when this module adopted
3083
- ${JSON.stringify(rule)}: "warn"`
3084
- ).join(",\n");
3716
+ const entries = holds.map(({ rule }) => ` ${JSON.stringify(rule)}: "warn"`).join(",\n");
3085
3717
  return [
3086
3718
  "{",
3087
3719
  ` // Written by @hublo/sentinel@${version}. Do not edit by hand: \`sentinel --init --lint\``,
@@ -3089,9 +3721,15 @@ function renderBaseline(holds, version) {
3089
3721
  " // and returns to `error` on its own.",
3090
3722
  " //",
3091
3723
  " // These rules are held at `warn` because this module ALREADY violated them when it",
3092
- " // adopted. They still run and still report; they just cannot fail the build for code",
3093
- " // that was there before the migration. Fix them and re-run `--init --lint` to get the",
3094
- " // preset severity back.",
3724
+ " // adopted. They still run and still report, so the build cannot fail on them.",
3725
+ " //",
3726
+ " // The hold is per RULE, not per occurrence: oxlint cannot freeze a specific list of",
3727
+ " // violations, so a NEW violation of one of these rules is covered too, and will only",
3728
+ " // warn. That is the cost of adopting without a red build, and the reason to clear this",
3729
+ " // file rather than live with it.",
3730
+ " //",
3731
+ " // `sentinel --inspect --lint` counts what is left. Fix them and re-run `--init --lint`",
3732
+ " // to get the preset severity back.",
3095
3733
  ' "rules": {',
3096
3734
  entries,
3097
3735
  " }",
@@ -3111,6 +3749,41 @@ function describeHolds(holds) {
3111
3749
  return `held ${holds.length} rule(s) at \`warn\` for this module, covering ${total} violation(s) that were already there: ${listed}${rest}. They are written to ${LINT_BASELINE_FILE}, they still report, and they return to \`error\` once fixed and re-initialized`;
3112
3750
  }
3113
3751
 
3752
+ // src/roles/lint/extra-layers.ts
3753
+ function ruleCount(cwd, specifier) {
3754
+ const path = isAbsolute2(specifier) ? specifier : resolve3(cwd, specifier);
3755
+ if (!existsSync13(path)) return void 0;
3756
+ try {
3757
+ const parsed = parseJsonc(
3758
+ readFileSync12(path, "utf8"),
3759
+ specifier
3760
+ );
3761
+ return Object.keys(parsed.rules ?? {}).length;
3762
+ } catch {
3763
+ return void 0;
3764
+ }
3765
+ }
3766
+ function extraLayers(cwd, extendsList) {
3767
+ return extendsList.filter((entry) => !isSentinelPreset(entry) && entry !== LINT_BASELINE_SPECIFIER).map((entry) => {
3768
+ const count = ruleCount(cwd, entry);
3769
+ return {
3770
+ rule: entry,
3771
+ reason: count === void 0 ? "this module extends it, but it could not be read from here" : `${count} rule(s) on top of the preset, applied after it`
3772
+ };
3773
+ });
3774
+ }
3775
+ function committedExtendsList(cwd, configFile) {
3776
+ try {
3777
+ const parsed = parseJsonc(
3778
+ readFileSync12(join15(cwd, configFile), "utf8"),
3779
+ configFile
3780
+ );
3781
+ return Array.isArray(parsed.extends) ? parsed.extends.filter((entry) => typeof entry === "string") : [];
3782
+ } catch {
3783
+ return [];
3784
+ }
3785
+ }
3786
+
3114
3787
  // src/roles/lint/parse-diagnostics.ts
3115
3788
  function ruleId(code) {
3116
3789
  const inner = /\(([^)]+)\)/.exec(code);
@@ -3161,8 +3834,8 @@ function errorCountsByConfigRule(stdout) {
3161
3834
  }
3162
3835
 
3163
3836
  // src/core/config/read-adoption.ts
3164
- import { existsSync as existsSync12, readFileSync as readFileSync11 } from "fs";
3165
- import { join as join14 } from "path";
3837
+ import { existsSync as existsSync15, readFileSync as readFileSync14 } from "fs";
3838
+ import { join as join17 } from "path";
3166
3839
 
3167
3840
  // src/core/config/owned-keys.ts
3168
3841
  function presetOwnedKeys(config, permitted, presetSets) {
@@ -3177,12 +3850,12 @@ function localOnlyKeys(config, permitted, presetSets) {
3177
3850
  }
3178
3851
 
3179
3852
  // src/core/config/resolve-config-target.ts
3180
- import { existsSync as existsSync11, readFileSync as readFileSync10 } from "fs";
3181
- import { join as join13 } from "path";
3853
+ import { existsSync as existsSync14, readFileSync as readFileSync13 } from "fs";
3854
+ import { join as join16 } from "path";
3182
3855
  function readExtends(absolutePath) {
3183
3856
  let parsed;
3184
3857
  try {
3185
- parsed = parseJsonc(readFileSync10(absolutePath, "utf8"), absolutePath);
3858
+ parsed = parseJsonc(readFileSync13(absolutePath, "utf8"), absolutePath);
3186
3859
  } catch {
3187
3860
  return [];
3188
3861
  }
@@ -3196,8 +3869,8 @@ function resolveConfigTarget(moduleDir, { candidates, markers, fallback }) {
3196
3869
  let existing;
3197
3870
  let existingExtendsSomething = false;
3198
3871
  for (const candidate of candidates) {
3199
- const absolutePath = join13(moduleDir, candidate);
3200
- if (!existsSync11(absolutePath)) continue;
3872
+ const absolutePath = join16(moduleDir, candidate);
3873
+ if (!existsSync14(absolutePath)) continue;
3201
3874
  const chain = readExtends(absolutePath);
3202
3875
  if (existing === void 0) {
3203
3876
  existing = candidate;
@@ -3220,7 +3893,7 @@ function normaliseExtends(value) {
3220
3893
  }
3221
3894
  return [];
3222
3895
  }
3223
- var NOT_ADOPTED2 = (configFile, unreadable = null) => ({
3896
+ var NOT_ADOPTED3 = (configFile, unreadable = null) => ({
3224
3897
  configFile,
3225
3898
  preset: null,
3226
3899
  adopted: false,
@@ -3230,18 +3903,18 @@ var NOT_ADOPTED2 = (configFile, unreadable = null) => ({
3230
3903
  });
3231
3904
  function readAdoption(cwd, options) {
3232
3905
  const target = resolveConfigTarget(cwd, options);
3233
- if (target.reason === "none" || !existsSync12(join14(cwd, target.path))) {
3234
- return NOT_ADOPTED2(target.reason === "none" ? null : target.path);
3906
+ if (target.reason === "none" || !existsSync15(join17(cwd, target.path))) {
3907
+ return NOT_ADOPTED3(target.reason === "none" ? null : target.path);
3235
3908
  }
3236
3909
  let parsed;
3237
3910
  try {
3238
- parsed = parseJsonc(readFileSync11(join14(cwd, target.path), "utf8"), target.path);
3911
+ parsed = parseJsonc(readFileSync14(join17(cwd, target.path), "utf8"), target.path);
3239
3912
  } catch (error) {
3240
3913
  const reason = error instanceof Error ? error.message : String(error);
3241
- return NOT_ADOPTED2(target.path, `${target.path} could not be parsed (${reason})`);
3914
+ return NOT_ADOPTED3(target.path, `${target.path} could not be parsed (${reason})`);
3242
3915
  }
3243
3916
  const preset = normaliseExtends(parsed.extends).find((entry) => options.presetPattern.test(entry)) ?? null;
3244
- if (preset === null) return NOT_ADOPTED2(target.path);
3917
+ if (preset === null) return NOT_ADOPTED3(target.path);
3245
3918
  const settings = options.settingsKey === null ? parsed : parsed[options.settingsKey];
3246
3919
  const drift = presetOwnedKeys(settings, options.permitted, options.presetOwns?.(preset));
3247
3920
  return {
@@ -3269,9 +3942,9 @@ function readLintAdoption(cwd) {
3269
3942
  }
3270
3943
 
3271
3944
  // src/roles/lint/resolve-oxlint.ts
3272
- import { existsSync as existsSync13 } from "fs";
3945
+ import { existsSync as existsSync16 } from "fs";
3273
3946
  import { createRequire as createRequire3 } from "module";
3274
- import { delimiter as delimiter2, dirname as dirname5, join as join15 } from "path";
3947
+ import { delimiter as delimiter2, dirname as dirname5, join as join18 } from "path";
3275
3948
  import { fileURLToPath as fileURLToPath2 } from "url";
3276
3949
  var PACKAGE_OF = {
3277
3950
  oxlint: "oxlint",
@@ -3296,30 +3969,30 @@ function tsgolintShim(cwd) {
3296
3969
  for (const owner of ["oxlint-tsgolint", "oxlint"]) {
3297
3970
  try {
3298
3971
  const packageDir = dirname5(require3.resolve(`${owner}/package.json`));
3299
- candidates.push(join15(packageDir, "node_modules", ".bin", "tsgolint"));
3300
- candidates.push(join15(packageDir, "..", ".bin", "tsgolint"));
3972
+ candidates.push(join18(packageDir, "node_modules", ".bin", "tsgolint"));
3973
+ candidates.push(join18(packageDir, "..", ".bin", "tsgolint"));
3301
3974
  } catch {
3302
3975
  }
3303
3976
  }
3304
3977
  try {
3305
3978
  const ownRoot = dirname5(require3.resolve("@hublo/sentinel/package.json"));
3306
- candidates.push(join15(ownRoot, "node_modules", ".bin", "tsgolint"));
3979
+ candidates.push(join18(ownRoot, "node_modules", ".bin", "tsgolint"));
3307
3980
  } catch {
3308
3981
  candidates.push(resolveBin(dirname5(fileURLToPath2(import.meta.url)), "tsgolint") ?? "");
3309
3982
  }
3310
- return candidates.find((candidate) => candidate !== "" && existsSync13(candidate));
3983
+ return candidates.find((candidate) => candidate !== "" && existsSync16(candidate));
3311
3984
  }
3312
3985
  function oxlintSearchPath(cwd) {
3313
3986
  return binSearchPath(cwd);
3314
3987
  }
3315
3988
 
3316
3989
  // src/roles/lint/adapters/oxlint/plan.ts
3317
- import { existsSync as existsSync15, readFileSync as readFileSync14 } from "fs";
3318
- import { join as join18 } from "path";
3990
+ import { existsSync as existsSync18, readFileSync as readFileSync17 } from "fs";
3991
+ import { join as join21 } from "path";
3319
3992
 
3320
3993
  // src/roles/lint/eslint-ignores.ts
3321
- import { existsSync as existsSync14, readFileSync as readFileSync12 } from "fs";
3322
- import { join as join16 } from "path";
3994
+ import { existsSync as existsSync17, readFileSync as readFileSync15 } from "fs";
3995
+ import { join as join19 } from "path";
3323
3996
  var IGNORE_BLOCKS = [/\bignores\s*:\s*\[([^\]]*)\]/g, /\bglobalIgnores\s*\(\s*\[([^\]]*)\]/g];
3324
3997
  var STRING_LITERAL = /['"`]([^'"`]+)['"`]/g;
3325
3998
  var OPAQUE_SOURCE = /\b(includeIgnoreFile)\s*\([^)]*\)/g;
@@ -3332,11 +4005,11 @@ function readRootEslintIgnores(root) {
3332
4005
  return { patterns: patterns.filter((pattern) => pattern.startsWith("**/")), unresolved };
3333
4006
  }
3334
4007
  function readEslintIgnores(cwd) {
3335
- const config = ESLINT_CONFIG_FILES.map((name) => join16(cwd, name)).find((path) => existsSync14(path));
4008
+ const config = ESLINT_CONFIG_FILES.map((name) => join19(cwd, name)).find((path) => existsSync17(path));
3336
4009
  if (!config) return { patterns: [], unresolved: [] };
3337
4010
  let source;
3338
4011
  try {
3339
- source = readFileSync12(config, "utf8");
4012
+ source = readFileSync15(config, "utf8");
3340
4013
  } catch {
3341
4014
  return { patterns: [], unresolved: [] };
3342
4015
  }
@@ -3390,8 +4063,8 @@ function lintPresetFor(preset) {
3390
4063
  }
3391
4064
 
3392
4065
  // src/roles/lint/rename-suppressions.ts
3393
- import { readdirSync as readdirSync2, readFileSync as readFileSync13, statSync } from "fs";
3394
- import { join as join17, relative as relative2 } from "path";
4066
+ import { readdirSync as readdirSync2, readFileSync as readFileSync16, statSync } from "fs";
4067
+ import { join as join20, relative as relative2 } from "path";
3395
4068
  var SOURCE_EXTENSIONS = [
3396
4069
  ".ts",
3397
4070
  ".tsx",
@@ -3438,7 +4111,7 @@ function* sourceFiles(dir) {
3438
4111
  return;
3439
4112
  }
3440
4113
  for (const entry of entries) {
3441
- const full = join17(dir, entry);
4114
+ const full = join20(dir, entry);
3442
4115
  let isDirectory;
3443
4116
  try {
3444
4117
  isDirectory = statSync(full).isDirectory();
@@ -3458,7 +4131,7 @@ function findSuppressionRenames(cwd, renames) {
3458
4131
  for (const file of sourceFiles(cwd)) {
3459
4132
  let content;
3460
4133
  try {
3461
- content = readFileSync13(file, "utf8");
4134
+ content = readFileSync16(file, "utf8");
3462
4135
  } catch {
3463
4136
  continue;
3464
4137
  }
@@ -3498,7 +4171,17 @@ var DEFAULT_IGNORE_PATTERNS = [
3498
4171
  "**/dist/**",
3499
4172
  "**/node_modules/**",
3500
4173
  "**/coverage/**",
3501
- "**/*.mock.ts",
4174
+ // `**/*.mock.ts` used to sit here and has been removed. It was the only entry in this list
4175
+ // with no reason written next to it, and it does not meet the bar the paragraph above sets:
4176
+ // a mock is not build output, not vendored and not generated. It is hand-written TypeScript
4177
+ // that ships with the tests, and lint rules have as much to say about it as about any other
4178
+ // source file. Ignoring it quietly narrowed what an adopted module checks — career lost
4179
+ // `src/test/router.mock.ts`, which its previous ESLint config did lint.
4180
+ //
4181
+ // Measured before removing, because this is the one change that can turn a green module red:
4182
+ // career has 1 such file and it reports nothing; the 15 planning modules have none. 222 exist
4183
+ // repo-wide, and modules adopting later meet them through the baseline, which is exactly what
4184
+ // the baseline is for.
3502
4185
  // Tool configuration is not source. A repo's shared ESLint config ignores these, and every
3503
4186
  // module inherits that; adoption replaces the config chain, so without them the first thing
3504
4187
  // an adopted module lints is its own jest setup file. Found on libs/front/components, where
@@ -3527,7 +4210,7 @@ function summariseByPlugin(rules) {
3527
4210
  }
3528
4211
  return [...counts.entries()].sort((left, right) => right[1] - left[1]).map(([plugin, count]) => `${count} ${plugin}`).join(", ");
3529
4212
  }
3530
- function plan(context) {
4213
+ function plan2(context) {
3531
4214
  if (!hasLintPreset(context.preset)) {
3532
4215
  return {
3533
4216
  operations: [],
@@ -3561,7 +4244,7 @@ function plan(context) {
3561
4244
  ].filter((pattern) => !defaults.has(bare(pattern)));
3562
4245
  const carried = [.../* @__PURE__ */ new Set([...DEFAULT_IGNORE_PATTERNS, ...moduleOwn])];
3563
4246
  const stub = {
3564
- extends: [presetPath(variant)],
4247
+ extends: extendsWithPreset(committedExtends(context.cwd), variant),
3565
4248
  ...carried.length > 0 ? { ignorePatterns: carried } : {}
3566
4249
  };
3567
4250
  const operations = [
@@ -3576,7 +4259,7 @@ function plan(context) {
3576
4259
  keys: removableDeps
3577
4260
  });
3578
4261
  }
3579
- const eslintConfigs = ESLINT_CONFIG_FILES.filter((name) => existsSync15(join18(context.cwd, name)));
4262
+ const eslintConfigs = ESLINT_CONFIG_FILES.filter((name) => existsSync18(join21(context.cwd, name)));
3580
4263
  for (const name of eslintConfigs) operations.push({ kind: "delete", path: name });
3581
4264
  operations.push(
3582
4265
  ...nxTargetOperations({
@@ -3671,10 +4354,21 @@ function lintScripts(cwd) {
3671
4354
  }
3672
4355
  return rewritten;
3673
4356
  }
4357
+ function committedExtends(cwd) {
4358
+ try {
4359
+ const parsed = parseJsonc(
4360
+ readFileSync17(join21(cwd, LINT_CONFIG_FILE), "utf8"),
4361
+ LINT_CONFIG_FILE
4362
+ );
4363
+ return Array.isArray(parsed.extends) ? parsed.extends.filter((entry) => typeof entry === "string") : [];
4364
+ } catch {
4365
+ return [];
4366
+ }
4367
+ }
3674
4368
  function committedIgnorePatterns(cwd) {
3675
4369
  try {
3676
4370
  const parsed = parseJsonc(
3677
- readFileSync14(join18(cwd, LINT_CONFIG_FILE), "utf8"),
4371
+ readFileSync17(join21(cwd, LINT_CONFIG_FILE), "utf8"),
3678
4372
  LINT_CONFIG_FILE
3679
4373
  );
3680
4374
  return Array.isArray(parsed.ignorePatterns) ? parsed.ignorePatterns.filter((entry) => typeof entry === "string") : [];
@@ -3686,7 +4380,7 @@ function moduleEslintDependencies(cwd) {
3686
4380
  const isEslintPackage = (name) => name === "eslint" || name === "@types/eslint" || name === "typescript-eslint" || name.startsWith("@typescript-eslint/") || name.startsWith("eslint-plugin-") || name.startsWith("eslint-config-") || name.startsWith("@eslint/");
3687
4381
  let manifest;
3688
4382
  try {
3689
- manifest = JSON.parse(readFileSync14(join18(cwd, "package.json"), "utf8"));
4383
+ manifest = JSON.parse(readFileSync17(join21(cwd, "package.json"), "utf8"));
3690
4384
  } catch {
3691
4385
  return [];
3692
4386
  }
@@ -3745,7 +4439,7 @@ var OxlintAdapter = class extends BaseAdapter {
3745
4439
  * lines. The adapter stays the contract with the engine.
3746
4440
  */
3747
4441
  plan(context) {
3748
- return plan(context);
4442
+ return plan2(context);
3749
4443
  }
3750
4444
  declaredPreset(cwd) {
3751
4445
  return presetOfVariant(presetNameFromPath(readLintAdoption(cwd).preset));
@@ -3755,7 +4449,7 @@ var OxlintAdapter = class extends BaseAdapter {
3755
4449
  * verification run that follows is the one a developer reads.
3756
4450
  */
3757
4451
  fixPass(ctx, oxlint, env) {
3758
- const result = spawnSync2(oxlint, ["-c", LINT_CONFIG_FILE, "--fix", "."], {
4452
+ const result = spawnSync5(oxlint, ["-c", LINT_CONFIG_FILE, "--fix", "."], {
3759
4453
  cwd: ctx.cwd,
3760
4454
  encoding: "utf8",
3761
4455
  env,
@@ -3805,20 +4499,20 @@ var OxlintAdapter = class extends BaseAdapter {
3805
4499
  * build the developer can see, rather than a silent half-adoption they cannot.
3806
4500
  */
3807
4501
  writeModuleBaseline(ctx, oxlint, env) {
3808
- const configPath = join19(ctx.cwd, LINT_CONFIG_FILE);
3809
- const baselinePath = join19(ctx.cwd, LINT_BASELINE_FILE);
3810
- if (!existsSync16(configPath)) return;
4502
+ const configPath = join22(ctx.cwd, LINT_CONFIG_FILE);
4503
+ const baselinePath = join22(ctx.cwd, LINT_BASELINE_FILE);
4504
+ if (!existsSync19(configPath)) return;
3811
4505
  let config;
3812
4506
  try {
3813
4507
  config = parseJsonc(
3814
- readFileSync15(configPath, "utf8"),
4508
+ readFileSync18(configPath, "utf8"),
3815
4509
  LINT_CONFIG_FILE
3816
4510
  );
3817
4511
  } catch {
3818
4512
  return;
3819
4513
  }
3820
4514
  const current = Array.isArray(config.extends) ? config.extends : [];
3821
- const measurePath = join19(ctx.cwd, LINT_MEASURE_FILE);
4515
+ const measurePath = join22(ctx.cwd, LINT_MEASURE_FILE);
3822
4516
  let measured;
3823
4517
  try {
3824
4518
  writeFileSync2(
@@ -3826,7 +4520,7 @@ var OxlintAdapter = class extends BaseAdapter {
3826
4520
  `${JSON.stringify({ ...config, extends: extendsWithBaseline(current, false) }, null, 2)}
3827
4521
  `
3828
4522
  );
3829
- measured = spawnSync2(
4523
+ measured = spawnSync5(
3830
4524
  oxlint,
3831
4525
  [
3832
4526
  "-c",
@@ -3866,7 +4560,7 @@ var OxlintAdapter = class extends BaseAdapter {
3866
4560
  }
3867
4561
  }
3868
4562
  async run(ctx) {
3869
- if (!existsSync16(join19(ctx.cwd, LINT_CONFIG_FILE))) {
4563
+ if (!existsSync19(join22(ctx.cwd, LINT_CONFIG_FILE))) {
3870
4564
  process.stderr.write(
3871
4565
  `sentinel lint(oxlint): no ${LINT_CONFIG_FILE} in this module; run \`sentinel --init --lint\` to adopt.
3872
4566
  `
@@ -3913,7 +4607,7 @@ var OxlintAdapter = class extends BaseAdapter {
3913
4607
  const targets = paths.length > 0 ? paths : ["."];
3914
4608
  const lint = (extra) => {
3915
4609
  const passed = [...extra, ...passedOptions];
3916
- const result = spawnSync2(oxlint, ["-c", LINT_CONFIG_FILE, ...passed, ...targets], {
4610
+ const result = spawnSync5(oxlint, ["-c", LINT_CONFIG_FILE, ...passed, ...targets], {
3917
4611
  cwd: ctx.cwd,
3918
4612
  stdio: "inherit",
3919
4613
  env
@@ -3950,7 +4644,12 @@ var OxlintAdapter = class extends BaseAdapter {
3950
4644
  downgraded: downgradedRulesFor(presetVariant(ctx.preset, ctx.cwd)).map((entry) => ({
3951
4645
  rule: entry.rule,
3952
4646
  reason: entry.reason
3953
- }))
4647
+ })),
4648
+ // What this module enforces BEYOND the preset. Not drift, and not covered by anything
4649
+ // else here: the stub still holds only `extends` and `ignorePatterns`, so a module with
4650
+ // a team layer reports `conformant=true drift=[]` and used to say nothing at all about
4651
+ // the extra rules it runs. See `extra-layers` for why that state is legitimate.
4652
+ layers: extraLayers(ctx.cwd, committedExtendsList(ctx.cwd, LINT_CONFIG_FILE))
3954
4653
  };
3955
4654
  }
3956
4655
  /** How many rules the extended preset enforces, read from the preset on disk. */
@@ -3981,7 +4680,7 @@ var OxlintAdapter = class extends BaseAdapter {
3981
4680
  let stub;
3982
4681
  try {
3983
4682
  stub = parseJsonc(
3984
- readFileSync15(join19(cwd, LINT_CONFIG_FILE), "utf8"),
4683
+ readFileSync18(join22(cwd, LINT_CONFIG_FILE), "utf8"),
3985
4684
  LINT_CONFIG_FILE
3986
4685
  );
3987
4686
  } catch {
@@ -3991,7 +4690,7 @@ var OxlintAdapter = class extends BaseAdapter {
3991
4690
  for (const entry of stub.extends ?? []) {
3992
4691
  try {
3993
4692
  const preset = parseJsonc(
3994
- readFileSync15(join19(cwd, entry), "utf8"),
4693
+ readFileSync18(join22(cwd, entry), "utf8"),
3995
4694
  entry
3996
4695
  );
3997
4696
  for (const rule of Object.keys(preset.rules ?? {})) names.add(rule);
@@ -4008,7 +4707,7 @@ var OxlintAdapter = class extends BaseAdapter {
4008
4707
  const oxlint = resolveOxlint(ctx.cwd);
4009
4708
  if (!oxlint) return { ok: false, code: 1, metrics: { error: "oxlint not found" } };
4010
4709
  const typeAware = canRunTypeAware(ctx.cwd) ? ["--type-aware"] : [];
4011
- const result = spawnSync2(oxlint, ["-c", LINT_CONFIG_FILE, ...typeAware, "-f", "json", "."], {
4710
+ const result = spawnSync5(oxlint, ["-c", LINT_CONFIG_FILE, ...typeAware, "-f", "json", "."], {
4012
4711
  cwd: ctx.cwd,
4013
4712
  encoding: "utf8",
4014
4713
  env: { ...process.env, PATH: oxlintPath(ctx.cwd, process.env) },
@@ -4061,14 +4760,14 @@ var OxlintAdapter = class extends BaseAdapter {
4061
4760
  let parsed;
4062
4761
  try {
4063
4762
  parsed = parseJsonc(
4064
- readFileSync15(join19(cwd, LINT_CONFIG_FILE), "utf8"),
4763
+ readFileSync18(join22(cwd, LINT_CONFIG_FILE), "utf8"),
4065
4764
  LINT_CONFIG_FILE
4066
4765
  );
4067
4766
  } catch {
4068
4767
  return void 0;
4069
4768
  }
4070
4769
  const targets = Array.isArray(parsed.extends) ? parsed.extends.filter((entry) => typeof entry === "string") : typeof parsed.extends === "string" ? [parsed.extends] : [];
4071
- return targets.find((target) => !existsSync16(join19(cwd, target)));
4770
+ return targets.find((target) => !existsSync19(join22(cwd, target)));
4072
4771
  }
4073
4772
  /** Announce what is not enforced, so reduced coverage is never silent. */
4074
4773
  announceDisabled(preset) {
@@ -4099,10 +4798,10 @@ function registerLint() {
4099
4798
  }
4100
4799
 
4101
4800
  // src/roles/typescript/adapters/tsc/tsc.adapter.ts
4102
- import { spawnSync as spawnSync3 } from "child_process";
4103
- import { existsSync as existsSync17, readFileSync as readFileSync17 } from "fs";
4801
+ import { spawnSync as spawnSync6 } from "child_process";
4802
+ import { existsSync as existsSync20, readFileSync as readFileSync20 } from "fs";
4104
4803
  import { createRequire as createRequire4 } from "module";
4105
- import { join as join21 } from "path";
4804
+ import { join as join24 } from "path";
4106
4805
 
4107
4806
  // src/roles/typescript/presets/base.json
4108
4807
  var base_default3 = {
@@ -4266,7 +4965,7 @@ function resolveTsconfigTarget(moduleDir) {
4266
4965
  }
4267
4966
 
4268
4967
  // src/roles/typescript/read-adoption.ts
4269
- var SENTINEL_PRESET = /^@hublo\/sentinel\/tsconfig\/[a-z-]+$/;
4968
+ var SENTINEL_PRESET = /^@hublo\/sentinel\/(?:typescript|tsconfig)\/[a-z-]+$/;
4270
4969
  function readTsconfigAdoption(cwd) {
4271
4970
  return readAdoption(cwd, {
4272
4971
  candidates: TSCONFIG_CANDIDATES,
@@ -4283,8 +4982,8 @@ function readTsconfigAdoption(cwd) {
4283
4982
  }
4284
4983
 
4285
4984
  // src/roles/typescript/adapters/tsc/plan.ts
4286
- import { readFileSync as readFileSync16 } from "fs";
4287
- import { join as join20 } from "path";
4985
+ import { readFileSync as readFileSync19 } from "fs";
4986
+ import { join as join23 } from "path";
4288
4987
 
4289
4988
  // src/roles/typescript/typecheck-script.ts
4290
4989
  var SENTINEL_TYPECHECK_COMMAND = "sentinel --run --typescript";
@@ -4306,7 +5005,10 @@ function keepsForeignChecker(existing) {
4306
5005
 
4307
5006
  // src/roles/typescript/adapters/tsc/plan.ts
4308
5007
  var INSTALL_NOTE = "run `pnpm install` to fetch @hublo/sentinel (added to the module devDependencies) so `extends` and the typecheck script resolve";
4309
- var TYPECHECK_TARGET = { cache: true, inputs: ["default", "{projectRoot}/tsconfig.json"] };
5008
+ var TYPECHECK_TARGET = {
5009
+ cache: true,
5010
+ inputs: ["default", "^default", "{projectRoot}/tsconfig.json"]
5011
+ };
4310
5012
  function typecheckScripts(cwd) {
4311
5013
  return {
4312
5014
  // Reads the nx target too, not only the npm script: five modules in this repo keep a
@@ -4315,9 +5017,11 @@ function typecheckScripts(cwd) {
4315
5017
  [TYPECHECK_SCRIPT_NAME]: composeTypecheckScript(existingCommand(cwd, TYPECHECK_SCRIPT_NAME))
4316
5018
  };
4317
5019
  }
5020
+ var SENTINEL_TS_PRESET = /@hublo\/sentinel\/(?:typescript|tsconfig)\//;
4318
5021
  function composeExtends(current, preset) {
4319
5022
  const chain = typeof current === "string" ? [current] : Array.isArray(current) ? current.filter((entry) => typeof entry === "string") : [];
4320
- return chain.includes(preset) ? chain : [...chain, preset];
5023
+ const withoutOwn = chain.filter((entry) => entry !== preset && !SENTINEL_TS_PRESET.test(entry));
5024
+ return [...withoutOwn, preset];
4321
5025
  }
4322
5026
  function declaredPreset(cwd) {
4323
5027
  const { preset } = readTsconfigAdoption(cwd);
@@ -4346,7 +5050,7 @@ function planAdoption(context) {
4346
5050
  };
4347
5051
  }
4348
5052
  const target = resolveTsconfigTarget(context.cwd);
4349
- const preset = `@hublo/sentinel/tsconfig/${context.preset}`;
5053
+ const preset = `@hublo/sentinel/typescript/${context.preset}`;
4350
5054
  const addScript = manifestOperation(context.cwd, typecheckScripts(context.cwd));
4351
5055
  const nxTargets = nxTargetOperations({
4352
5056
  cwd: context.cwd,
@@ -4372,7 +5076,7 @@ function planAdoption(context) {
4372
5076
  };
4373
5077
  }
4374
5078
  const existing = parseJsonc(
4375
- readFileSync16(join20(context.cwd, target.path), "utf8"),
5079
+ readFileSync19(join23(context.cwd, target.path), "utf8"),
4376
5080
  target.path
4377
5081
  );
4378
5082
  const extendsChain = composeExtends(existing.extends, preset);
@@ -4410,7 +5114,7 @@ function planAdoption(context) {
4410
5114
 
4411
5115
  // src/roles/typescript/adapters/tsc/tsc.adapter.ts
4412
5116
  var SENTINEL_PACKAGE = "@hublo/sentinel";
4413
- var SENTINEL_PRESET_SCOPE = `${SENTINEL_PACKAGE}/tsconfig/`;
5117
+ var SENTINEL_PRESET_SCOPES = [`${SENTINEL_PACKAGE}/typescript/`, `${SENTINEL_PACKAGE}/tsconfig/`];
4414
5118
  var DIAGNOSTIC_RE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.+)$/;
4415
5119
  function parseDiagnostics(output) {
4416
5120
  const diagnostics = [];
@@ -4510,7 +5214,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
4510
5214
  let chain;
4511
5215
  try {
4512
5216
  const parsed = parseJsonc(
4513
- readFileSync17(join21(cwd, target.path), "utf8"),
5217
+ readFileSync20(join24(cwd, target.path), "utf8"),
4514
5218
  target.path
4515
5219
  );
4516
5220
  chain = parsed.extends;
@@ -4519,11 +5223,11 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
4519
5223
  }
4520
5224
  const entries = typeof chain === "string" ? [chain] : Array.isArray(chain) ? chain : [];
4521
5225
  const preset = entries.find(
4522
- (entry) => typeof entry === "string" && entry.startsWith(SENTINEL_PRESET_SCOPE)
5226
+ (entry) => typeof entry === "string" && SENTINEL_PRESET_SCOPES.some((scope) => entry.startsWith(scope))
4523
5227
  );
4524
5228
  if (preset === void 0) return void 0;
4525
5229
  try {
4526
- createRequire4(join21(cwd, "noop.js")).resolve(preset);
5230
+ createRequire4(join24(cwd, "noop.js")).resolve(preset);
4527
5231
  return void 0;
4528
5232
  } catch {
4529
5233
  return preset;
@@ -4564,7 +5268,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
4564
5268
  return { ok: false, code: 1 };
4565
5269
  }
4566
5270
  if (options.length > 0) return this.runWithOptions(ctx, tsc, config);
4567
- const result = spawnSync3(tsc, ["-b", config], { cwd: ctx.cwd, stdio: "inherit" });
5271
+ const result = spawnSync6(tsc, ["-b", config], { cwd: ctx.cwd, stdio: "inherit" });
4568
5272
  if (result.error) {
4569
5273
  process.stderr.write(
4570
5274
  `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
@@ -4599,7 +5303,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
4599
5303
  let worst = 0;
4600
5304
  for (const project of this.referencedProjects(ctx.cwd, config)) {
4601
5305
  const args = ["-p", project, "--noEmit", "--composite", "false", ...ctx.toolArgs ?? []];
4602
- const result = spawnSync3(tsc, args, { cwd: ctx.cwd, stdio: "inherit" });
5306
+ const result = spawnSync6(tsc, args, { cwd: ctx.cwd, stdio: "inherit" });
4603
5307
  if (result.error) {
4604
5308
  process.stderr.write(
4605
5309
  `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
@@ -4619,7 +5323,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
4619
5323
  referencedProjects(cwd, config) {
4620
5324
  try {
4621
5325
  const parsed = parseJsonc(
4622
- readFileSync17(join21(cwd, config), "utf8"),
5326
+ readFileSync20(join24(cwd, config), "utf8"),
4623
5327
  config
4624
5328
  );
4625
5329
  const referenced = (parsed.references ?? []).map((reference) => reference.path).filter((path) => typeof path === "string" && path.length > 0);
@@ -4635,7 +5339,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
4635
5339
  * check.
4636
5340
  */
4637
5341
  typecheckTarget(cwd) {
4638
- if (existsSync17(join21(cwd, "tsconfig.json"))) return "tsconfig.json";
5342
+ if (existsSync20(join24(cwd, "tsconfig.json"))) return "tsconfig.json";
4639
5343
  const target = resolveTsconfigTarget(cwd);
4640
5344
  return target.reason === "none" ? null : target.path;
4641
5345
  }
@@ -4688,7 +5392,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
4688
5392
  return { ok: true, code: 0, metrics: _TscAdapter.NOTHING_TO_REPORT };
4689
5393
  }
4690
5394
  const tsc = resolveBin(ctx.cwd, "tsc") ?? "tsc";
4691
- const result = spawnSync3(tsc, ["-b", config], { cwd: ctx.cwd, encoding: "utf8" });
5395
+ const result = spawnSync6(tsc, ["-b", config], { cwd: ctx.cwd, encoding: "utf8" });
4692
5396
  if (result.error) {
4693
5397
  process.stderr.write(
4694
5398
  `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
@@ -4731,7 +5435,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
4731
5435
  * errors in the output mean the rule must be on.
4732
5436
  */
4733
5437
  noImplicitAnyEnabled(cwd, tsc, config, mainOutput) {
4734
- const shown = spawnSync3(tsc, ["-p", config, "--showConfig"], { cwd, encoding: "utf8" });
5438
+ const shown = spawnSync6(tsc, ["-p", config, "--showConfig"], { cwd, encoding: "utf8" });
4735
5439
  if (shown.status === 0 && shown.stdout) {
4736
5440
  try {
4737
5441
  const co = parseJsonc(
@@ -4757,6 +5461,7 @@ function registerAdapters() {
4757
5461
  registerTypescript();
4758
5462
  registerLint();
4759
5463
  registerFormat();
5464
+ registerBuild();
4760
5465
  }
4761
5466
 
4762
5467
  // src/core/detect-framework.ts
@@ -4838,26 +5543,19 @@ function replaceLines(current, replacements) {
4838
5543
  }
4839
5544
 
4840
5545
  // src/core/apply-plan.ts
4841
- import { existsSync as existsSync18, readFileSync as readFileSync18, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
4842
- import { resolve as resolve3, sep } from "path";
5546
+ import { existsSync as existsSync21, readFileSync as readFileSync21, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
5547
+ import { resolve as resolve4, sep } from "path";
4843
5548
  import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
4844
-
4845
- // src/shared/deep-merge.ts
4846
- function isPlainObject(value) {
4847
- return typeof value === "object" && value !== null && !Array.isArray(value);
4848
- }
4849
-
4850
- // src/core/apply-plan.ts
4851
5549
  function resolveWithinRoot(cwd, relativePath) {
4852
- const root = resolve3(cwd);
4853
- const absolutePath = resolve3(root, relativePath);
5550
+ const root = resolve4(cwd);
5551
+ const absolutePath = resolve4(root, relativePath);
4854
5552
  if (absolutePath !== root && !absolutePath.startsWith(root + sep)) {
4855
5553
  throw new Error(`Refusing to write outside the module root: "${relativePath}".`);
4856
5554
  }
4857
5555
  return absolutePath;
4858
5556
  }
4859
5557
  function readIfExists(absolutePath) {
4860
- return existsSync18(absolutePath) ? readFileSync18(absolutePath, "utf8") : void 0;
5558
+ return existsSync21(absolutePath) ? readFileSync21(absolutePath, "utf8") : void 0;
4861
5559
  }
4862
5560
  function* leaves(value, prefix = []) {
4863
5561
  for (const [key, keyValue] of Object.entries(value)) {
@@ -4938,9 +5636,9 @@ function applyOperationTo(current, operation) {
4938
5636
  }
4939
5637
  }
4940
5638
  }
4941
- function preparePlan(cwd, plan2) {
5639
+ function preparePlan(cwd, plan3) {
4942
5640
  const prepared = /* @__PURE__ */ new Map();
4943
- for (const operation of plan2.operations) {
5641
+ for (const operation of plan3.operations) {
4944
5642
  const absolutePath = resolveWithinRoot(cwd, operation.path);
4945
5643
  const existing = prepared.get(operation.path);
4946
5644
  const before = existing?.before ?? readIfExists(absolutePath) ?? "";
@@ -4962,8 +5660,8 @@ function writeFileAtomic(absolutePath, contents) {
4962
5660
  writeFileSync3(tempPath, contents);
4963
5661
  renameSync(tempPath, absolutePath);
4964
5662
  }
4965
- function applyPlan(cwd, plan2) {
4966
- const changed = preparePlan(cwd, plan2).filter((file) => file.before !== file.after);
5663
+ function applyPlan(cwd, plan3) {
5664
+ const changed = preparePlan(cwd, plan3).filter((file) => file.before !== file.after);
4967
5665
  for (const file of changed) {
4968
5666
  if (file.deleted) {
4969
5667
  rmSync2(file.absolutePath, { force: true });
@@ -4975,8 +5673,8 @@ function applyPlan(cwd, plan2) {
4975
5673
  }
4976
5674
 
4977
5675
  // src/core/config/preset-evidence.ts
4978
- import { existsSync as existsSync19, readdirSync as readdirSync3, readFileSync as readFileSync19 } from "fs";
4979
- import { join as join22 } from "path";
5676
+ import { existsSync as existsSync22, readdirSync as readdirSync3, readFileSync as readFileSync22 } from "fs";
5677
+ import { join as join25 } from "path";
4980
5678
  var PATH_SIGNALS = [
4981
5679
  {
4982
5680
  preset: "nest",
@@ -4990,11 +5688,11 @@ var DEPENDENCY_SIGNALS = [
4990
5688
  { preset: "nest", pattern: /^@nestjs\// }
4991
5689
  ];
4992
5690
  function dependencyNames(cwd) {
4993
- const path = join22(cwd, "package.json");
4994
- if (!existsSync19(path)) return [];
5691
+ const path = join25(cwd, "package.json");
5692
+ if (!existsSync22(path)) return [];
4995
5693
  try {
4996
5694
  const manifest = parseJsonc(
4997
- readFileSync19(path, "utf8"),
5695
+ readFileSync22(path, "utf8"),
4998
5696
  path
4999
5697
  );
5000
5698
  return [
@@ -5017,7 +5715,7 @@ function declaresJsx(cwd) {
5017
5715
  for (const name of entries) {
5018
5716
  try {
5019
5717
  const config = parseJsonc(
5020
- readFileSync19(join22(cwd, name), "utf8"),
5718
+ readFileSync22(join25(cwd, name), "utf8"),
5021
5719
  name
5022
5720
  );
5023
5721
  if (config.compilerOptions?.jsx !== void 0) return true;
@@ -5068,14 +5766,14 @@ function resolveFlavour(opts) {
5068
5766
  }
5069
5767
  return detection.preset;
5070
5768
  }
5071
- function previewPlan(opts, plan2) {
5072
- const changed = preparePlan(opts.cwd, plan2).filter((file) => file.before !== file.after);
5769
+ function previewPlan(opts, plan3) {
5770
+ const changed = preparePlan(opts.cwd, plan3).filter((file) => file.before !== file.after);
5073
5771
  if (opts.json) {
5074
5772
  process.stdout.write(
5075
5773
  JSON.stringify(
5076
5774
  {
5077
5775
  dryRun: true,
5078
- notes: plan2.notes ?? [],
5776
+ notes: plan3.notes ?? [],
5079
5777
  files: changed.map(({ path, before, after, deleted }) => ({
5080
5778
  path,
5081
5779
  action: deleted ? "delete" : before.length === 0 ? "create" : "update",
@@ -5090,7 +5788,7 @@ function previewPlan(opts, plan2) {
5090
5788
  return 0;
5091
5789
  }
5092
5790
  process.stderr.write(" dry run: no files written\n");
5093
- for (const note of plan2.notes ?? []) process.stderr.write(` ${note}
5791
+ for (const note of plan3.notes ?? []) process.stderr.write(` ${note}
5094
5792
  `);
5095
5793
  if (changed.length === 0) {
5096
5794
  process.stderr.write(" nothing to change\n");
@@ -5111,7 +5809,8 @@ async function dispatch(opts) {
5111
5809
  throw new Error(`dispatch handles --init only; --${opts.verb} routes through analyse()`);
5112
5810
  }
5113
5811
  const detected = resolveFlavour(opts);
5114
- const adapter = resolve(opts.target, detected, opts.runner);
5812
+ const resolutionPreset = opts.preset ?? declaredPresetFor(opts.target, opts.cwd) ?? detected;
5813
+ const adapter = resolve(opts.target, resolutionPreset, opts.runner);
5115
5814
  const preset = opts.preset ?? adapter.declaredPreset?.(opts.cwd) ?? detected;
5116
5815
  const contradiction = presetContradiction(preset, opts.cwd);
5117
5816
  if (contradiction !== void 0) {
@@ -5120,25 +5819,25 @@ async function dispatch(opts) {
5120
5819
  return 1;
5121
5820
  }
5122
5821
  const context = { cwd: opts.cwd, preset };
5123
- const plan2 = await adapter.plan(context);
5124
- if (plan2.blocked) {
5125
- process.stderr.write(`sentinel (${opts.target}): ${plan2.blocked}
5822
+ const plan3 = await adapter.plan(context);
5823
+ if (plan3.blocked) {
5824
+ process.stderr.write(`sentinel (${opts.target}): ${plan3.blocked}
5126
5825
  `);
5127
5826
  return 1;
5128
5827
  }
5129
- if (plan2.skipped) {
5130
- process.stderr.write(` ${palette(process.stderr).dim(`${opts.target}: ${plan2.skipped}`)}
5828
+ if (plan3.skipped) {
5829
+ process.stderr.write(` ${palette(process.stderr).dim(`${opts.target}: ${plan3.skipped}`)}
5131
5830
  `);
5132
5831
  return 0;
5133
5832
  }
5134
5833
  if (opts.dryRun) {
5135
- return previewPlan(opts, plan2);
5834
+ return previewPlan(opts, plan3);
5136
5835
  }
5137
- for (const change of applyPlan(opts.cwd, plan2)) {
5836
+ for (const change of applyPlan(opts.cwd, plan3)) {
5138
5837
  process.stderr.write(` ${change.deleted ? "removed" : "wrote"} ${change.path}
5139
5838
  `);
5140
5839
  }
5141
- for (const note of plan2.notes ?? []) process.stderr.write(` ${note}
5840
+ for (const note of plan3.notes ?? []) process.stderr.write(` ${note}
5142
5841
  `);
5143
5842
  if (adapter.afterInit) {
5144
5843
  process.stderr.write(` fixing what ${opts.target} can fix automatically...
@@ -5155,26 +5854,30 @@ async function dispatch(opts) {
5155
5854
  }
5156
5855
 
5157
5856
  export {
5857
+ PresetUnsupportedError,
5158
5858
  register,
5159
5859
  setDefaultRunner,
5160
5860
  all,
5861
+ declaredPresetFor,
5161
5862
  availableTargets,
5162
5863
  resolve,
5163
5864
  BaseAdapter,
5865
+ resolveBin,
5164
5866
  readOwnVersion,
5165
5867
  readProjectPackageJson,
5166
5868
  readNxProjectName,
5167
5869
  WORKSPACE_ROOT_MARKER,
5168
5870
  findWorkspaceRoot,
5169
5871
  ensureWorkspacePrep,
5872
+ inspectWorkspacePrep,
5170
5873
  palette,
5171
- resolveBin,
5172
5874
  VERBS,
5173
5875
  TARGETS,
5876
+ SWEEPABLE_TARGETS,
5174
5877
  PRESET_NAMES,
5175
5878
  registerAdapters,
5176
5879
  describeFramework,
5177
5880
  detectFramework,
5178
5881
  dispatch
5179
5882
  };
5180
- //# sourceMappingURL=chunk-RLVLDC5H.js.map
5883
+ //# sourceMappingURL=chunk-TWL6T237.js.map