@hublo/sentinel 1.2.0-alpha.0 → 1.2.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,3 @@
1
- import {
2
- REACT_APP_DEFAULTS,
3
- isPlainObject
4
- } from "./chunk-XSCDJZDY.js";
5
-
6
1
  // src/core/registry.ts
7
2
  var adapters = [];
8
3
  var PresetUnsupportedError = class extends Error {
@@ -133,20 +128,6 @@ function readNxProjectName(dir) {
133
128
  }
134
129
  }
135
130
 
136
- // src/shared/color.ts
137
- import { styleText } from "util";
138
- function palette(stream) {
139
- const paint = (format, text) => styleText(format, text, { stream });
140
- return {
141
- ok: (text) => paint(["green", "bold"], text),
142
- fail: (text) => paint(["red", "bold"], text),
143
- warn: (text) => paint("yellow", text),
144
- strong: (text) => paint("bold", text),
145
- dim: (text) => paint("dim", text),
146
- accent: (text) => paint("cyan", text)
147
- };
148
- }
149
-
150
131
  // src/core/domain.ts
151
132
  var VERBS = ["run", "inspect", "init", "migrate", "report", "status"];
152
133
  var TARGETS = [
@@ -166,7 +147,21 @@ var SWEEPABLE_TARGETS = TARGETS.filter(
166
147
  );
167
148
  var PRESET_NAMES = ["react", "nest", "svelte", "node", "tools"];
168
149
 
169
- // src/roles/build/adapters/vite/vite-dev.adapter.ts
150
+ // src/shared/color.ts
151
+ import { styleText } from "util";
152
+ function palette(stream) {
153
+ const paint = (format, text) => styleText(format, text, { stream });
154
+ return {
155
+ ok: (text) => paint(["green", "bold"], text),
156
+ fail: (text) => paint(["red", "bold"], text),
157
+ warn: (text) => paint("yellow", text),
158
+ strong: (text) => paint("bold", text),
159
+ dim: (text) => paint("dim", text),
160
+ accent: (text) => paint("cyan", text)
161
+ };
162
+ }
163
+
164
+ // src/roles/build/adapters/vite/vite-role.adapter.ts
170
165
  import { spawnSync } from "child_process";
171
166
 
172
167
  // src/core/config/tool-args.ts
@@ -206,9 +201,9 @@ function binFromOwnInstall(packageName, binName) {
206
201
  try {
207
202
  const manifest = require2.resolve(`${packageName}/package.json`);
208
203
  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);
204
+ const relative4 = typeof bin === "string" ? bin : bin?.[binName];
205
+ if (!relative4) return void 0;
206
+ const executable = join2(dirname2(manifest), relative4);
212
207
  return existsSync3(executable) ? executable : void 0;
213
208
  } catch {
214
209
  return void 0;
@@ -222,7 +217,7 @@ function binSearchPath(cwd) {
222
217
 
223
218
  // src/roles/build/plan.ts
224
219
  import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
225
- import { join as join7 } from "path";
220
+ import { basename as basename2, join as join7 } from "path";
226
221
 
227
222
  // src/core/config/existing-command.ts
228
223
  import { readFileSync as readFileSync3 } from "fs";
@@ -383,7 +378,85 @@ function keepsOtherCommands(script, sentinelCommand) {
383
378
  return script.split(SEGMENT_SEPARATOR).filter((_, index) => !isSeparatorAt(index)).some((segment) => segment.trim() !== "" && segment.trim() !== sentinelCommand);
384
379
  }
385
380
 
381
+ // src/roles/build/presets/requirements.json
382
+ var requirements_default = {
383
+ svelte: {
384
+ why: "sentinel ships Vite 8, and @sveltejs/vite-plugin-svelte caps at Vite 6 until its major 7",
385
+ requires: {
386
+ "@sveltejs/vite-plugin-svelte": "7.0.0",
387
+ svelte: "5.46.4"
388
+ }
389
+ }
390
+ };
391
+
392
+ // src/roles/build/presets/toolchain.json
393
+ var toolchain_default = {
394
+ $comment: "The build toolchain sentinel owns. One table, because three places used to hold parts of it: the list to remove from a module's package.json, the list of import specifiers to rewrite, and the map from a default import to the name sentinel re-exports it under. A package added to one and not the others gives a removal with no rewrite, or an import pointing at a package the module no longer declares.",
395
+ owned: [
396
+ {
397
+ package: "vite",
398
+ specifier: "vite",
399
+ named: ["defineConfig", "loadEnv", "mergeConfig"],
400
+ types: [
401
+ "Plugin",
402
+ "PluginOption",
403
+ "SassPreprocessorOptions",
404
+ "UserConfig",
405
+ "UserConfigExport"
406
+ ],
407
+ why: "the bundler itself. `defineConfig`, `loadEnv` and `mergeConfig` are what every config in this repo imports from it, and the types travel with them: a config importing PluginOption from a package it no longer declares builds but does not typecheck."
408
+ },
409
+ {
410
+ package: "@vitejs/plugin-react",
411
+ specifier: "@vitejs/plugin-react",
412
+ default: "react",
413
+ why: "the React transform. Bound to the Vite that runs it, so it has to come from the same install."
414
+ },
415
+ {
416
+ package: "@tailwindcss/vite",
417
+ specifier: "@tailwindcss/vite",
418
+ default: "tailwindcss",
419
+ why: "the Tailwind pipeline, identical in all three front apps."
420
+ },
421
+ {
422
+ package: "vite-plugin-svgr",
423
+ specifier: "vite-plugin-svgr",
424
+ default: "svgr",
425
+ why: "SVG as components, identical in all three front apps."
426
+ },
427
+ {
428
+ package: "nitro",
429
+ specifier: "nitro/vite",
430
+ named: ["nitro"],
431
+ why: "the SSR server. Note the package and the import specifier differ, which is exactly the kind of split that made three separate lists disagree."
432
+ }
433
+ ],
434
+ add: [
435
+ {
436
+ package: "@hublo/sentinel",
437
+ why: "the module now gets its toolchain from here, so it declares it. Pinned by the installer rather than by us: `--init` writes the dependency, `pnpm install` resolves it."
438
+ }
439
+ ],
440
+ $notOwned: {
441
+ "@rolldown/plugin-babel": "declared by career and host-admin themselves, not at the root. It supports LocatorJS, a development convenience gated on LOCATORJS=true, not shared build infrastructure. Removing it would remove a feature from two teams, which is their call and must not ride along inside an adoption.",
442
+ "@locator/babel-jsx": "same, and same owners.",
443
+ "@tanstack/react-start": "a framework the apps code against: 48 source files import it directly.",
444
+ "@tanstack/router-plugin": "generates the app's own route tree.",
445
+ "vite-bundle-analyzer": "at the root at 1.3.7 and imported nowhere, the only mention being a commented-out line in host-admin. Not a candidate to re-export, a candidate to delete from the root."
446
+ }
447
+ };
448
+
449
+ // src/roles/build/preset-data.ts
450
+ var REQUIREMENTS = requirements_default;
451
+ var OWNED_PACKAGES = toolchain_default.owned;
452
+
386
453
  // src/roles/build/config-policy.ts
454
+ var COMPANION_CONFIG_FILES = [
455
+ "vitest.config.ts",
456
+ "vitest.config.mts",
457
+ "vitest.config.js",
458
+ "vitest.config.mjs"
459
+ ];
387
460
  var BUILD_CONFIG_FILES = [
388
461
  "vite.config.ts",
389
462
  "vite.config.mts",
@@ -399,17 +472,13 @@ function buildTarget() {
399
472
  return {
400
473
  [BUILD_SCRIPT_NAME]: {
401
474
  cache: true,
402
- inputs: ["default", "^default", `{projectRoot}/${BUILD_CONFIG_FILES[0]}`]
475
+ inputs: ["default", "^default", ...BUILD_CONFIG_FILES.map((name) => `{projectRoot}/${name}`)]
403
476
  }
404
477
  };
405
478
  }
406
- var SENTINEL_OWNED_BUILD_PACKAGES = [
407
- "vite",
408
- "@vitejs/plugin-react",
409
- "@tailwindcss/vite",
410
- "vite-plugin-svgr",
411
- "nitro"
412
- ];
479
+ var SENTINEL_OWNED_BUILD_PACKAGES = OWNED_PACKAGES.map(
480
+ (entry) => entry.package
481
+ );
413
482
 
414
483
  // src/roles/build/dev-script.ts
415
484
  var CHAIN = " -- ";
@@ -446,17 +515,24 @@ var NOT_ADOPTED = (configFile, unreadable = null) => ({
446
515
  function buildConfigFile(cwd) {
447
516
  return BUILD_CONFIG_FILES.find((name) => existsSync6(join6(cwd, name)));
448
517
  }
518
+ function importsSpecifier(source, specifier) {
519
+ const escaped = specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
520
+ return new RegExp(`(?:from|import)\\s*\\(?\\s*['"\`]${escaped}(?:/[^'"\`]*)?['"\`]`).test(source);
521
+ }
449
522
  function importsPreset(source) {
450
- const specifier = BUILD_PRESET_SPECIFIER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
451
- return new RegExp(`(?:from|import)\\s*\\(?\\s*['"\`]${specifier}['"\`]`).test(source);
523
+ return importsSpecifier(source, BUILD_PRESET_SPECIFIER);
452
524
  }
453
525
  var REACT_PLUGIN_SPECIFIERS = ["@vitejs/plugin-react", "@tanstack/react-start"];
454
526
  function declaredBuildPreset(cwd) {
527
+ const source = readBuildConfigSource(cwd);
528
+ if (source === void 0) return void 0;
529
+ return REACT_PLUGIN_SPECIFIERS.some((specifier) => importsSpecifier(source, specifier)) ? "react" : void 0;
530
+ }
531
+ function readBuildConfigSource(cwd) {
455
532
  const configFile = buildConfigFile(cwd);
456
533
  if (!configFile) return void 0;
457
534
  try {
458
- const source = readFileSync4(join6(cwd, configFile), "utf8");
459
- return REACT_PLUGIN_SPECIFIERS.some((specifier) => source.includes(specifier)) ? "react" : void 0;
535
+ return readFileSync4(join6(cwd, configFile), "utf8");
460
536
  } catch {
461
537
  return void 0;
462
538
  }
@@ -489,39 +565,131 @@ function readBuildAdoption(cwd) {
489
565
  };
490
566
  }
491
567
 
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'
568
+ // src/roles/build/requirements.ts
569
+ function atLeast(declared, needed) {
570
+ const parse2 = (value) => value.replace(/^[\^~>=<\s]+/, "").split("-")[0].split(".").map((part) => Number.parseInt(part, 10) || 0);
571
+ const left = parse2(declared);
572
+ const right = parse2(needed);
573
+ for (let at = 0; at < Math.max(left.length, right.length); at++) {
574
+ const a = left[at] ?? 0;
575
+ const b = right[at] ?? 0;
576
+ if (a !== b) return a > b;
577
+ }
578
+ return true;
579
+ }
580
+ function unmetRequirements(preset, declared) {
581
+ const entry = REQUIREMENTS[preset];
582
+ if (!entry) return [];
583
+ return Object.entries(entry.requires).filter(([name, needed]) => {
584
+ const have = declared[name];
585
+ return have === void 0 || !atLeast(have, needed);
586
+ }).map(([name, needed]) => ({ name, declared: declared[name], needed }));
587
+ }
588
+ function describeUnmet(preset, unmet) {
589
+ const entry = REQUIREMENTS[preset];
590
+ const list = unmet.map(
591
+ ({ name, declared, needed }) => `${name} ${declared === void 0 ? "is not declared here" : `is at ${declared}`}, needs >= ${needed}`
592
+ ).join("; ");
593
+ return `the build role does not apply to this module yet: ${list}. ${entry?.why ?? ""}. Nothing was written. Raise those versions, check the module still builds and its tests still pass, then re-run \`sentinel --init --build\`.`;
594
+ }
498
595
 
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
- `;
596
+ // src/roles/build/rewrite-imports.ts
597
+ import { parseSync } from "oxc-parser";
598
+ var MOVED_SPECIFIERS = new Set(OWNED_PACKAGES.map((entry) => entry.specifier));
599
+ function esmBlocker(fileName, packageType) {
600
+ if (/\.(mts|mjs)$/.test(fileName)) return void 0;
601
+ if (packageType === "module") return void 0;
602
+ return `sentinel is ESM only, and this module does not declare \`"type": "module"\`, so Vite would load ${fileName} as CommonJS and fail to require it. Add \`"type": "module"\` to this module's package.json, or rename the config to \`${fileName.replace(/\.(ts|js)$/, ".m$1")}\`, then run this again.`;
603
+ }
604
+ var DEFAULT_EXPORT_NAMES = Object.fromEntries(
605
+ OWNED_PACKAGES.filter((entry) => entry.default !== void 0).map((entry) => [
606
+ entry.specifier,
607
+ entry.default
608
+ ])
609
+ );
610
+ function renderImport(bindings) {
611
+ const render = (list) => list.map((b) => b.exported === b.local ? b.exported : `${b.exported} as ${b.local}`).sort((left, right) => left.localeCompare(right)).join(", ");
612
+ const values = bindings.filter((b) => !b.typeOnly);
613
+ const types = bindings.filter((b) => b.typeOnly);
614
+ const lines = [];
615
+ if (values.length > 0) lines.push(`import { ${render(values)} } from '${BUILD_PRESET_SPECIFIER}'`);
616
+ if (types.length > 0) {
617
+ lines.push(`import type { ${render(types)} } from '${BUILD_PRESET_SPECIFIER}'`);
618
+ }
619
+ return lines.join("\n");
620
+ }
621
+ function rewriteBuildImports(source, fileName = "vite.config.ts") {
622
+ const { program, errors } = parseSync(fileName, source, { sourceType: "module" });
623
+ if (errors.length > 0) {
624
+ return {
625
+ kind: "blocked",
626
+ why: `${fileName} does not parse: ${errors[0]?.message ?? "unknown"}`
627
+ };
628
+ }
629
+ const imports = program.body.filter(
630
+ (node) => node.type === "ImportDeclaration"
631
+ );
632
+ if (imports.some((node) => node.source.value === BUILD_PRESET_SPECIFIER)) {
633
+ return { kind: "unchanged", why: "this config already imports its toolchain from sentinel" };
634
+ }
635
+ const moving = imports.filter((node) => MOVED_SPECIFIERS.has(node.source.value));
636
+ if (moving.length === 0) {
637
+ return {
638
+ kind: "unchanged",
639
+ why: "this config imports none of the packages sentinel owns, so there is nothing to move"
640
+ };
641
+ }
642
+ const bindings = [];
643
+ for (const node of moving) {
644
+ const from = node.source.value;
645
+ const specifiers = node.specifiers ?? [];
646
+ if (specifiers.length === 0) {
647
+ return { kind: "blocked", why: `\`import '${from}'\` has no bindings to move` };
648
+ }
649
+ for (const spec of specifiers) {
650
+ const typeOnly = node.importKind === "type" || spec.importKind === "type";
651
+ if (spec.type === "ImportDefaultSpecifier") {
652
+ const exported = DEFAULT_EXPORT_NAMES[from];
653
+ if (!exported) {
654
+ return {
655
+ kind: "blocked",
656
+ why: `no sentinel export for the default import of \`${from}\``
657
+ };
658
+ }
659
+ bindings.push({ exported, local: spec.local.name, typeOnly });
660
+ continue;
661
+ }
662
+ if (spec.type === "ImportSpecifier") {
663
+ bindings.push({
664
+ exported: spec.imported?.name ?? spec.imported?.value ?? spec.local.name,
665
+ local: spec.local.name,
666
+ typeOnly
667
+ });
668
+ continue;
669
+ }
670
+ return {
671
+ kind: "blocked",
672
+ why: `\`import * as ${spec.local.name} from '${from}'\` cannot be mapped onto sentinel's named exports`
673
+ };
674
+ }
675
+ }
676
+ const spans = moving.map((node) => ({ start: node.start, end: node.end })).sort((left, right) => left.start - right.start);
677
+ const first = spans[0];
678
+ const rest = spans.slice(1);
679
+ let out = source;
680
+ for (const span of rest.slice().reverse()) {
681
+ const after = out.slice(span.end).match(/^\r?\n/)?.[0].length ?? 0;
682
+ out = out.slice(0, span.start) + out.slice(span.end + after);
683
+ }
684
+ out = out.slice(0, first.start) + renderImport(bindings) + out.slice(first.end);
685
+ return {
686
+ kind: "rewritten",
687
+ source: out,
688
+ moved: [...new Set(moving.map((node) => node.source.value))].sort()
689
+ };
524
690
  }
691
+
692
+ // src/roles/build/plan.ts
525
693
  function ownedBuildDependencies(cwd) {
526
694
  const manifest = readProjectPackageJson(cwd);
527
695
  const keys = [];
@@ -550,32 +718,68 @@ function buildScripts(cwd) {
550
718
  };
551
719
  const existingDev = existingCommand(cwd, DEV_SCRIPT_NAME);
552
720
  if (existingDev !== void 0) {
721
+ const own2 = basename2(cwd);
553
722
  scripts[DEV_SCRIPT_NAME] = composeDevScript(
554
723
  existingDev,
555
- selfCommand(cwd, "--run --dev") ?? SENTINEL_DEV_COMMAND
724
+ `${selfCommand(cwd, "--run --dev") ?? SENTINEL_DEV_COMMAND} --module ${own2}`
556
725
  );
557
726
  }
558
727
  return scripts;
559
728
  }
560
729
  function plan(context) {
730
+ const manifest = readProjectPackageJson(context.cwd);
731
+ const unmet = unmetRequirements(context.preset, {
732
+ ...manifest.dependencies,
733
+ ...manifest.devDependencies
734
+ });
735
+ if (unmet.length > 0) {
736
+ return { operations: [], skipped: describeUnmet(context.preset, unmet) };
737
+ }
561
738
  if (declaredBuildPreset(context.cwd) === void 0 && buildConfigFile(context.cwd) !== void 0) {
562
739
  return {
563
740
  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.`
741
+ 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.`
565
742
  };
566
743
  }
567
744
  const notes = [];
568
745
  const operations = [];
569
746
  const configFile = buildConfigFile(context.cwd);
570
747
  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
- );
748
+ return {
749
+ operations: [],
750
+ skipped: `no Vite config in this module, so there is nothing to point at sentinel. This role moves an existing config's imports; it does not introduce a bundler. If this module builds another way (38 projects here build through \`@nx/webpack\`), the build role does not apply to it.`
751
+ };
752
+ } else {
753
+ const notEsm = esmBlocker(configFile, readProjectPackageJson(context.cwd).type);
754
+ if (notEsm !== void 0) return { operations: [], skipped: notEsm };
755
+ const rewrite = rewriteBuildImports(readConfigSource(context.cwd, configFile), configFile);
756
+ if (rewrite.kind === "blocked") {
757
+ return {
758
+ operations: [],
759
+ skipped: `${configFile} could not be pointed at sentinel, so nothing was written: ${rewrite.why}. Nothing here is broken; this needs a look before the role applies.`
760
+ };
761
+ }
762
+ if (rewrite.kind === "rewritten") {
763
+ operations.push({ kind: "write", path: configFile, contents: rewrite.source });
764
+ }
765
+ for (const name of COMPANION_CONFIG_FILES) {
766
+ const source = readConfigSource(context.cwd, name);
767
+ if (source === "") continue;
768
+ const companion = rewriteBuildImports(source, name);
769
+ if (companion.kind === "rewritten") {
770
+ operations.push({ kind: "write", path: name, contents: companion.source });
771
+ notes.push(`${name} now imports ${companion.moved.join(", ")} from sentinel too.`);
772
+ } else if (companion.kind === "blocked") {
773
+ notes.push(
774
+ `${name} imports the toolchain and could NOT be pointed at sentinel: ${companion.why}. It will stop resolving when the workspace root drops these packages, and it will fail your TESTS rather than your build.`
775
+ );
776
+ }
777
+ }
778
+ if (rewrite.kind === "rewritten") {
779
+ notes.push(
780
+ `${configFile} now imports ${rewrite.moved.join(", ")} from \`${BUILD_PRESET_SPECIFIER}\`. Only the import lines changed: every attribute, alias and path is untouched, so this build produces what it produced before.`
781
+ );
782
+ }
579
783
  }
580
784
  operations.push(manifestOperation(context.cwd, buildScripts(context.cwd)));
581
785
  operations.push(...nxTargetOperations({ cwd: context.cwd, targets: buildTarget() }));
@@ -588,134 +792,160 @@ function plan(context) {
588
792
  }
589
793
  return { operations, notes };
590
794
  }
591
- function readsPreset(cwd, configFile) {
592
- if (!existsSync7(join7(cwd, configFile))) return false;
795
+ function readConfigSource(cwd, configFile) {
796
+ if (!existsSync7(join7(cwd, configFile))) return "";
593
797
  try {
594
- return readFileSync5(join7(cwd, configFile), "utf8").includes(BUILD_PRESET_SPECIFIER);
798
+ return readFileSync5(join7(cwd, configFile), "utf8");
595
799
  } catch {
596
- return false;
800
+ return "";
597
801
  }
598
802
  }
599
803
 
600
804
  // src/roles/build/resolve-vite.ts
601
- function resolveVite(cwd) {
602
- return binFromOwnInstall("vite", "vite") ?? resolveBin(cwd, "vite");
805
+ function resolveViteWith(cwd) {
806
+ const own = binFromOwnInstall("vite", "vite");
807
+ if (own) return { bin: own, origin: "sentinel" };
808
+ const module_ = resolveBin(cwd, "vite");
809
+ return module_ ? { bin: module_, origin: "module" } : { bin: void 0, origin: "none" };
603
810
  }
604
811
  function viteOrigin(cwd) {
605
- if (binFromOwnInstall("vite", "vite")) return "sentinel";
606
- return resolveBin(cwd, "vite") ? "module" : "none";
812
+ return resolveViteWith(cwd).origin;
607
813
  }
608
814
 
609
- // src/roles/build/adapters/vite/vite-dev.adapter.ts
610
- var ViteDevAdapter = class extends BaseAdapter {
611
- target = "dev";
815
+ // src/roles/build/adapters/vite/vite-role.adapter.ts
816
+ var VITE_VALUE_FLAGS = [
817
+ "--config",
818
+ "-c",
819
+ "--mode",
820
+ "-m",
821
+ "--outDir",
822
+ "--logLevel",
823
+ "--base",
824
+ "--host",
825
+ "--port"
826
+ ];
827
+ var ViteRoleAdapter = class extends BaseAdapter {
612
828
  runner = "vite";
829
+ /**
830
+ * The presets this role has something to SAY about, which is wider than the ones it serves.
831
+ *
832
+ * React it serves. Svelte it does not, yet, and the difference matters to a developer: the
833
+ * plugin caps at Vite 6 until its major 7, which needs `svelte ^5.46.4`, so the role applies
834
+ * once the module moves. `plan` reports that chain and SKIPS, which it can only do if
835
+ * resolution let it run at all — filtering here would give "no adapter for preset svelte",
836
+ * which tells nobody what to do about it.
837
+ *
838
+ * Nest, node and tools are genuinely declined: there is nothing to bundle, and no version
839
+ * would change that.
840
+ */
613
841
  appliesTo(preset) {
614
- return preset === "react";
842
+ return preset === "react" || preset === "svelte";
615
843
  }
844
+ /**
845
+ * `react`, read from the module's Vite config rather than from its dependencies.
846
+ *
847
+ * Without this the role is unreachable in practice: the toolchain is declared at the ROOT,
848
+ * so every front app detects as `node` and `appliesTo` filters the adapter out — including
849
+ * during `sentinel --inspect` with no target, which never passes `--preset`.
850
+ */
616
851
  declaredPreset(cwd) {
617
852
  return declaredBuildPreset(cwd);
618
853
  }
619
854
  /**
620
- * The same plan as `--build`, deliberately.
855
+ * The same plan for both targets, deliberately.
621
856
  *
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.
857
+ * `build` and `serve` are one app's two calls into one toolchain, so adopting one without the
858
+ * other leaves the module half-migrated in a way nothing reports. The operations are
859
+ * idempotent, so `--init --build` and `--init --dev` are interchangeable rather than additive.
626
860
  */
627
861
  plan(context) {
628
862
  return plan(context);
629
863
  }
864
+ async status(ctx) {
865
+ const { adopted, preset, conformant, drift, unreadable } = readBuildAdoption(ctx.cwd);
866
+ return { adopted, preset, conformant, drift, unreadable };
867
+ }
630
868
  /**
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.
869
+ * Adoption, the binary, and the warning about which copy of Vite is running.
633
870
  *
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.
871
+ * A module that has not adopted is reported and PASSES, the same stance every other role
872
+ * takes: `--run` with no named target sweeps every wired target across the workspace, so
873
+ * failing here would exit non-zero on every module that has not migrated, which is most of
874
+ * them.
637
875
  */
638
- async run(ctx) {
876
+ prepare(ctx) {
639
877
  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
- `
878
+ this.say(
879
+ `no sentinel preset in this module's Vite config; run \`sentinel --init --build\` to adopt.`
643
880
  );
644
- return { ok: true, code: 0 };
881
+ return { kind: "stop", result: { ok: true, code: 0 } };
645
882
  }
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
- `
883
+ const { bin, origin } = resolveViteWith(ctx.cwd);
884
+ if (!bin) {
885
+ this.say(
886
+ `could not find the vite binary (looked in ${binSearchPath(ctx.cwd)}). Run \`pnpm install\` in the module.`
651
887
  );
652
- return { ok: false, code: 1 };
888
+ return { kind: "stop", result: { ok: false, code: 1 } };
653
889
  }
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
- `
890
+ if (origin === "module") {
891
+ this.say(
892
+ `using the MODULE's vite, not sentinel's. An adopted config gets its plugins from sentinel, and two copies of Vite fail in ways that never mention a version. Remove vite from this module's package.json.`
658
893
  );
659
894
  }
660
- const { options, paths } = splitToolArgs(ctx.cwd, ctx.toolArgs, { valueFlags: [] });
661
- const result = spawnSync(vite, [...options, ...paths], { cwd: ctx.cwd, stdio: "inherit" });
895
+ return { kind: "ready", vite: bin };
896
+ }
897
+ /**
898
+ * Run Vite with the caller's own arguments after `--`, and report what happened.
899
+ *
900
+ * `whenSignalled` is the only difference between the two targets here, and it is a real one:
901
+ * a build killed by a signal did not produce a bundle, while a dev server killed by Ctrl-C
902
+ * did exactly what the developer asked.
903
+ */
904
+ spawnVite(ctx, vite, argv, whenSignalled) {
905
+ const { options, paths } = splitToolArgs(ctx.cwd, ctx.toolArgs, {
906
+ valueFlags: VITE_VALUE_FLAGS
907
+ });
908
+ const result = spawnSync(vite, [...argv, ...options, ...paths], {
909
+ cwd: ctx.cwd,
910
+ stdio: "inherit"
911
+ });
662
912
  if (result.error) {
663
- process.stderr.write(`sentinel dev(vite): could not run vite (${result.error.message})
664
- `);
913
+ this.say(`could not run vite (${result.error.message})`);
665
914
  return { ok: false, code: 1 };
666
915
  }
667
- const code = result.status ?? 0;
916
+ const code = result.status ?? whenSignalled;
668
917
  return { ok: code === 0, code };
669
918
  }
670
- async status(ctx) {
671
- const { adopted, preset, conformant, drift, unreadable } = readBuildAdoption(ctx.cwd);
672
- return { adopted, preset, conformant, drift, unreadable };
919
+ /** One prefix for every message this role emits, so they cannot drift apart. */
920
+ say(message) {
921
+ process.stderr.write(`sentinel ${this.label}(vite): ${message}
922
+ `);
673
923
  }
674
924
  };
675
925
 
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
- });
926
+ // src/roles/build/adapters/vite/vite-dev.adapter.ts
927
+ var ViteDevAdapter = class extends ViteRoleAdapter {
928
+ target = "dev";
929
+ get label() {
930
+ return "dev";
698
931
  }
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
- });
932
+ /**
933
+ * Start the dev server. It does not return until stopped, so there is no verdict to report
934
+ * beyond the exit code the developer's own Ctrl-C produces.
935
+ *
936
+ * No `prebuild` here, unlike `--run --build`: the artefact this repo generates before a build
937
+ * is produced by the watcher that WRAPS this command (`run-with-runtime-artifact-watch`), and
938
+ * running it again would race the watcher that is about to own the file.
939
+ *
940
+ * No subcommand either. `vite`, `vite dev` and `vite serve` all start the server, and the
941
+ * bare form is the one every version accepts.
942
+ */
943
+ async run(ctx) {
944
+ const ready = this.prepare(ctx);
945
+ if (ready.kind === "stop") return ready.result;
946
+ return this.spawnVite(ctx, ready.vite, [], 0);
706
947
  }
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
- }
948
+ };
719
949
 
720
950
  // src/roles/build/prerequisite.ts
721
951
  import { spawnSync as spawnSync2 } from "child_process";
@@ -743,122 +973,55 @@ function runPrerequisite(cwd, env = process.env) {
743
973
  return { kind: "ran" };
744
974
  }
745
975
 
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
976
  // 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 {
977
+ var ViteAdapter = class extends ViteRoleAdapter {
766
978
  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);
979
+ get label() {
980
+ return "build";
787
981
  }
788
982
  /**
789
983
  * Build the module.
790
984
  *
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.
985
+ * Adoption, the binary and the two-copies warning are `prepare`'s, shared with `--dev`. What
986
+ * is this target's own is the `prebuild` step and the `build` subcommand.
795
987
  */
796
988
  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
- }
989
+ const ready = this.prepare(ctx);
990
+ if (ready.kind === "stop") return ready.result;
805
991
  const prerequisite = runPrerequisite(ctx.cwd);
806
992
  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
- `
993
+ this.say(
994
+ `the ${PREBUILD_SCRIPT_NAME} step failed, so the build was not started: ${prerequisite.reason}`
810
995
  );
811
996
  return { ok: false, code: prerequisite.code };
812
997
  }
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 };
998
+ return this.spawnVite(ctx, ready.vite, ["build"], 1);
841
999
  }
842
1000
  /**
843
- * What this module builds with, without building it.
1001
+ * What this module builds with, WITHOUT loading its config.
844
1002
  *
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.
1003
+ * Everything reported here is read from the manifest and from the config's text: whether the
1004
+ * module imports its toolchain from sentinel, which Vite install would answer, what it still
1005
+ * declares of its own, and whether it has a `prebuild` step.
848
1006
  *
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.
1007
+ * It used to also load the config through Vite and report where it departed from a preset
1008
+ * and what it added on top. Both were dropped with the preset itself. Adoption changes a
1009
+ * config's IMPORTS and nothing else, so there is no preset the app signed up to and no
1010
+ * distance from it to measure; and computing it meant EXECUTING an app's config, which is
1011
+ * expensive and can fail on its own terms (career throws without `VITE_BRAND_ID`). Reading
1012
+ * a file to report on it must not be able to break.
852
1013
  *
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.
1014
+ * They come back with the shared base, when there is again something an app has chosen to
1015
+ * adopt and can therefore depart from.
855
1016
  */
856
1017
  async inspect(ctx) {
857
1018
  const adoption = readBuildAdoption(ctx.cwd);
858
- const base = {
1019
+ return {
859
1020
  runner: "vite",
860
1021
  configFile: adoption.configFile,
861
1022
  ...adoption.unreadable ? { unreadable: adoption.unreadable } : {},
1023
+ // The field nothing else reports: an adopted config takes its plugins from sentinel, so
1024
+ // a module answering `module` here is one build away from the two-copies failure.
862
1025
  vite: viteOrigin(ctx.cwd),
863
1026
  // Rendered as a labelled block by the shared renderer, the same shape the lint role's
864
1027
  // parked rules and the format role's overrides already use.
@@ -868,34 +1031,6 @@ var ViteAdapter = class extends BaseAdapter {
868
1031
  })),
869
1032
  prerequisite: moduleScripts(ctx.cwd)[PREBUILD_SCRIPT_NAME] ?? null
870
1033
  };
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
- };
899
1034
  }
900
1035
  /**
901
1036
  * Adoption as data, WITHOUT building.
@@ -920,10 +1055,6 @@ var ViteAdapter = class extends BaseAdapter {
920
1055
  }
921
1056
  };
922
1057
  }
923
- async status(ctx) {
924
- const { adopted, preset, conformant, drift, unreadable } = readBuildAdoption(ctx.cwd);
925
- return { adopted, preset, conformant, drift, unreadable };
926
- }
927
1058
  };
928
1059
 
929
1060
  // src/roles/build/register.ts
@@ -935,13 +1066,13 @@ function registerBuild() {
935
1066
  }
936
1067
 
937
1068
  // src/roles/format/adapters/oxfmt/oxfmt.adapter.ts
938
- import { spawnSync as spawnSync4 } from "child_process";
1069
+ import { spawnSync as spawnSync3 } from "child_process";
939
1070
  import { existsSync as existsSync12, readFileSync as readFileSync11 } from "fs";
940
1071
  import { join as join14 } from "path";
941
1072
 
942
1073
  // src/core/config/has-source.ts
943
1074
  import { readdirSync } from "fs";
944
- import { extname, join as join8 } from "path";
1075
+ import { extname, join as join8, relative } from "path";
945
1076
  var SKIP_DIRECTORIES = /* @__PURE__ */ new Set([
946
1077
  "node_modules",
947
1078
  "dist",
@@ -1000,6 +1131,35 @@ function hasSourceFiles(cwd, extensions) {
1000
1131
  }
1001
1132
  return false;
1002
1133
  }
1134
+ function listSourceFiles(cwd, extensions) {
1135
+ const wanted = new Set(extensions);
1136
+ const queue = [cwd];
1137
+ const found = [];
1138
+ while (queue.length > 0) {
1139
+ const dir = queue.pop();
1140
+ let entries;
1141
+ try {
1142
+ entries = readdirSync(dir, { withFileTypes: true });
1143
+ } catch {
1144
+ continue;
1145
+ }
1146
+ for (const entry of entries) {
1147
+ const full = join8(dir, entry.name);
1148
+ if (entry.isDirectory()) {
1149
+ if (!SKIP_DIRECTORIES.has(entry.name)) queue.push(full);
1150
+ continue;
1151
+ }
1152
+ if (wanted.has(extname(entry.name))) found.push(relative(cwd, full));
1153
+ }
1154
+ }
1155
+ return found;
1156
+ }
1157
+
1158
+ // src/core/named-scope.ts
1159
+ var TOLERATE_EMPTY_FLAG = "--no-error-on-unmatched-pattern";
1160
+ function scopeFlags(paths) {
1161
+ return paths.length > 0 ? [TOLERATE_EMPTY_FLAG] : [];
1162
+ }
1003
1163
 
1004
1164
  // src/core/settings.ts
1005
1165
  var WORKSPACE_ROOT_MARKER = "nx.json";
@@ -1007,7 +1167,7 @@ var DEFAULT_MAX_DIAGNOSTICS = 100;
1007
1167
 
1008
1168
  // src/core/workspace-prep.ts
1009
1169
  import { existsSync as existsSync8, readFileSync as readFileSync6, writeFileSync } from "fs";
1010
- import { dirname as dirname3, join as join9, relative } from "path";
1170
+ import { dirname as dirname3, join as join9, relative as relative2 } from "path";
1011
1171
  var OVERRIDE_KEY = "i18next>typescript";
1012
1172
  var NATIVE_TS_ALIAS = "@typescript/native";
1013
1173
  var WORKSPACE_YAML = "pnpm-workspace.yaml";
@@ -1080,7 +1240,7 @@ var ROOT_PRETTIER_CONFIGS = [
1080
1240
  "prettier.config.mjs"
1081
1241
  ];
1082
1242
  function ensureFormatterExclusion(root, moduleDir, dryRun) {
1083
- const rel = relative(root, moduleDir).replaceAll("\\", "/");
1243
+ const rel = relative2(root, moduleDir).replaceAll("\\", "/");
1084
1244
  if (rel === "" || rel.startsWith("..")) return void 0;
1085
1245
  const ignorePath = join9(root, PRETTIER_IGNORE);
1086
1246
  const hasPrettier = existsSync8(ignorePath) || ROOT_PRETTIER_CONFIGS.some((name) => existsSync8(join9(root, name)));
@@ -1527,6 +1687,22 @@ var OxfmtAdapter = class extends BaseAdapter {
1527
1687
  appliesTo(_preset) {
1528
1688
  return true;
1529
1689
  }
1690
+ /**
1691
+ * The preset this module COMMITTED to, read from the `$sentinel` block it writes.
1692
+ *
1693
+ * Every other role implements this and format did not, so it was the only one answering from
1694
+ * dependency detection alone. On an adopted Nest module whose own `.oxfmtrc.json` records
1695
+ * `"preset": "nest"`, `--inspect` reported `flavour=node` — the module knew, and the role did
1696
+ * not pass it on. Same command, four rows, two different answers about what the module is.
1697
+ *
1698
+ * It reads the RECORD rather than re-deriving: the block is what `--init` wrote, so it is the
1699
+ * module's declaration, and a re-derivation from hoisted dependencies is exactly the wrong
1700
+ * answer this exists to avoid.
1701
+ */
1702
+ declaredPreset(cwd) {
1703
+ const declared = readFormatAdoption(cwd).preset;
1704
+ return declared !== null && PRESET_NAMES.includes(declared) ? declared : void 0;
1705
+ }
1530
1706
  plan(context) {
1531
1707
  if (!hasSourceFiles(context.cwd, FORMATTABLE_EXTENSIONS)) {
1532
1708
  return {
@@ -1628,7 +1804,7 @@ var OxfmtAdapter = class extends BaseAdapter {
1628
1804
  async afterInit(ctx) {
1629
1805
  const oxfmt = resolveOxfmt(ctx.cwd);
1630
1806
  if (!oxfmt) return;
1631
- const pass = spawnSync4(oxfmt, ["--write", "."], { cwd: ctx.cwd, encoding: "utf8" });
1807
+ const pass = spawnSync3(oxfmt, ["--write", "."], { cwd: ctx.cwd, encoding: "utf8" });
1632
1808
  process.stderr.write(` ${palette(process.stderr).dim(FORMATTER_DIFFERENCES)}
1633
1809
  `);
1634
1810
  if (pass.status === 0) return;
@@ -1691,10 +1867,14 @@ var OxfmtAdapter = class extends BaseAdapter {
1691
1867
  const { options, paths } = splitToolArgs(ctx.cwd, ctx.toolArgs, {
1692
1868
  valueFlags: OXFMT_VALUE_FLAGS
1693
1869
  });
1694
- const result = spawnSync4(oxfmt, [mode, ...options, ...paths.length > 0 ? paths : ["."]], {
1695
- cwd: ctx.cwd,
1696
- stdio: "inherit"
1697
- });
1870
+ const result = spawnSync3(
1871
+ oxfmt,
1872
+ [mode, ...scopeFlags(paths), ...options, ...paths.length > 0 ? paths : ["."]],
1873
+ {
1874
+ cwd: ctx.cwd,
1875
+ stdio: "inherit"
1876
+ }
1877
+ );
1698
1878
  if (result.error) {
1699
1879
  process.stderr.write(
1700
1880
  `sentinel format(oxfmt): could not run oxfmt (${result.error.message})
@@ -1769,7 +1949,7 @@ var OxfmtAdapter = class extends BaseAdapter {
1769
1949
  );
1770
1950
  return { ok: false, code: 1, metrics: { ...base, unformatted: null } };
1771
1951
  }
1772
- const result = spawnSync4(oxfmt, ["--list-different", "."], { cwd: ctx.cwd, encoding: "utf8" });
1952
+ const result = spawnSync3(oxfmt, ["--list-different", "."], { cwd: ctx.cwd, encoding: "utf8" });
1773
1953
  if (result.error) {
1774
1954
  process.stderr.write(
1775
1955
  `sentinel format(oxfmt): could not run oxfmt (${result.error.message})
@@ -1884,15 +2064,122 @@ function registerFormat() {
1884
2064
  register(new OxfmtAdapter());
1885
2065
  setDefaultRunner("format", "oxfmt");
1886
2066
  }
1887
-
1888
- // src/roles/lint/adapters/oxlint/oxlint.adapter.ts
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";
1892
-
1893
- // src/core/config/deferred-rules.ts
1894
- function deferredRuleNames(rules) {
1895
- return rules.map((entry) => entry.rule);
2067
+
2068
+ // src/roles/lint/adapters/oxlint/oxlint.adapter.ts
2069
+ import { spawnSync as spawnSync4 } from "child_process";
2070
+ import { existsSync as existsSync20, readFileSync as readFileSync19, rmSync, writeFileSync as writeFileSync2 } from "fs";
2071
+ import { join as join24 } from "path";
2072
+
2073
+ // src/core/config/deferred-rules.ts
2074
+ function deferredRuleNames(rules) {
2075
+ return rules.map((entry) => entry.rule);
2076
+ }
2077
+
2078
+ // src/core/fix-report.ts
2079
+ import { statSync } from "fs";
2080
+ import { join as join15 } from "path";
2081
+ var CONFIG_REFUSED = /failed to parse .*config|invalid config file/i;
2082
+ function readFailure(output) {
2083
+ const lines = output.split("\n").map((line2) => line2.trim());
2084
+ if (!lines.some((line2) => CONFIG_REFUSED.test(line2))) return void 0;
2085
+ const detail = lines.find((line2) => /invalid config file/i.test(line2));
2086
+ const line = (detail ?? lines.find((entry) => entry !== "") ?? "").replace(/^x\s*/, "");
2087
+ return line.split(/:\s*Failed to parse config/i)[0] ?? line;
2088
+ }
2089
+ function fingerprint(path) {
2090
+ try {
2091
+ const stat = statSync(path);
2092
+ return `${stat.size}:${stat.mtimeMs}`;
2093
+ } catch {
2094
+ return void 0;
2095
+ }
2096
+ }
2097
+ function snapshotFiles(cwd, extensions) {
2098
+ const snapshot = /* @__PURE__ */ new Map();
2099
+ for (const file of listSourceFiles(cwd, extensions)) {
2100
+ const mark = fingerprint(join15(cwd, file));
2101
+ if (mark !== void 0) snapshot.set(file, mark);
2102
+ }
2103
+ return snapshot;
2104
+ }
2105
+ function changedSince(cwd, before, extensions) {
2106
+ const changed = [];
2107
+ for (const file of listSourceFiles(cwd, extensions)) {
2108
+ const now = fingerprint(join15(cwd, file));
2109
+ if (now === void 0) continue;
2110
+ if (before.get(file) !== now) changed.push(file);
2111
+ }
2112
+ return changed.sort();
2113
+ }
2114
+ var NAMED_IN_REPORT = 5;
2115
+ function describeFixOutcome(outcome, toolLabel) {
2116
+ if (outcome.failure !== void 0) {
2117
+ return `the ${toolLabel} autofix could not run, so nothing was fixed and this module has not been checked: ${outcome.failure}`;
2118
+ }
2119
+ if (outcome.changed.length === 0) return `the ${toolLabel} autofix changed nothing`;
2120
+ const named = outcome.changed.slice(0, NAMED_IN_REPORT).join(", ");
2121
+ const rest = outcome.changed.length - NAMED_IN_REPORT;
2122
+ const listed = rest > 0 ? `${named} and ${rest} more` : named;
2123
+ return `the ${toolLabel} autofix rewrote ${outcome.changed.length} file${outcome.changed.length === 1 ? "" : "s"}: ${listed}. Review the diff before committing: rules this module used to have switched off are enforced from now on.`;
2124
+ }
2125
+
2126
+ // src/roles/lint/module-baseline.ts
2127
+ import { existsSync as existsSync13, readFileSync as readFileSync12 } from "fs";
2128
+ import { join as join16 } from "path";
2129
+ var LINT_BASELINE_FILE = ".oxlintrc.baseline.json";
2130
+ var LINT_MEASURE_FILE = ".oxlintrc.measure.json";
2131
+ var LINT_BASELINE_SPECIFIER = `./${LINT_BASELINE_FILE}`;
2132
+ function holdsFrom(errorCounts) {
2133
+ return [...errorCounts].map(([rule, count]) => ({ rule, count })).sort((left, right) => right.count - left.count || left.rule.localeCompare(right.rule));
2134
+ }
2135
+ function renderBaseline(holds, version) {
2136
+ const entries = holds.map(({ rule }) => ` ${JSON.stringify(rule)}: "warn"`).join(",\n");
2137
+ return [
2138
+ "{",
2139
+ ` // Written by @hublo/sentinel@${version}. Do not edit by hand: \`sentinel --init --lint\``,
2140
+ " // regenerates it from a fresh measurement, and a rule you have since fixed drops out",
2141
+ " // and returns to `error` on its own.",
2142
+ " //",
2143
+ " // These rules are held at `warn` because this module ALREADY violated them when it",
2144
+ " // adopted. They still run and still report, so the build cannot fail on them.",
2145
+ " //",
2146
+ " // The hold is per RULE, not per occurrence: oxlint cannot freeze a specific list of",
2147
+ " // violations, so a NEW violation of one of these rules is covered too, and will only",
2148
+ " // warn. That is the cost of adopting without a red build, and the reason to clear this",
2149
+ " // file rather than live with it.",
2150
+ " //",
2151
+ " // `sentinel --inspect --lint` lists what is held; `sentinel --run --lint --json` counts",
2152
+ " // what is left, per rule. Fix them and re-run `--init --lint` to get the severity back.",
2153
+ ' "rules": {',
2154
+ entries,
2155
+ " }",
2156
+ "}",
2157
+ ""
2158
+ ].join("\n");
2159
+ }
2160
+ function extendsWithBaseline(current, hasHolds) {
2161
+ const withoutBaseline = current.filter((entry) => entry !== LINT_BASELINE_SPECIFIER);
2162
+ return hasHolds ? [...withoutBaseline, LINT_BASELINE_SPECIFIER] : withoutBaseline;
2163
+ }
2164
+ function describeHolds(holds) {
2165
+ if (holds.length === 0) return "";
2166
+ const total = holds.reduce((sum, hold) => sum + hold.count, 0);
2167
+ const listed = holds.slice(0, 5).map(({ rule, count }) => `${rule} (${count})`).join(", ");
2168
+ const rest = holds.length > 5 ? `, and ${holds.length - 5} more` : "";
2169
+ 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`;
2170
+ }
2171
+ function heldRules(cwd) {
2172
+ const path = join16(cwd, LINT_BASELINE_FILE);
2173
+ if (!existsSync13(path)) return [];
2174
+ try {
2175
+ const parsed = parseJsonc(
2176
+ readFileSync12(path, "utf8"),
2177
+ LINT_BASELINE_FILE
2178
+ );
2179
+ return Object.keys(parsed.rules ?? {});
2180
+ } catch {
2181
+ return [];
2182
+ }
1896
2183
  }
1897
2184
 
1898
2185
  // src/roles/lint/config-policy.ts
@@ -1907,8 +2194,10 @@ function isSentinelPreset(entry) {
1907
2194
  }
1908
2195
  function extendsWithPreset(current, variant) {
1909
2196
  const own = presetPath(variant);
1910
- const others = current.filter((entry) => entry !== own && !isSentinelPreset(entry));
1911
- return [own, ...new Set(others)];
2197
+ const others = [...new Set(current.filter((entry) => entry !== own && !isSentinelPreset(entry)))];
2198
+ const baseline = others.filter((entry) => entry === LINT_BASELINE_SPECIFIER);
2199
+ const rest = others.filter((entry) => entry !== LINT_BASELINE_SPECIFIER);
2200
+ return [own, ...rest, ...baseline];
1912
2201
  }
1913
2202
  function presetVariant(preset, cwd) {
1914
2203
  const normalised = cwd.replaceAll("\\", "/");
@@ -1932,46 +2221,6 @@ function lintTarget() {
1932
2221
  return { cache: true, inputs: ["default", "^default", `{projectRoot}/${LINT_CONFIG_FILE}`] };
1933
2222
  }
1934
2223
 
1935
- // src/roles/lint/presets/base.json
1936
- var base_default2 = {
1937
- rules: {
1938
- "no-var": "error",
1939
- "prefer-const": [
1940
- "error",
1941
- {
1942
- destructuring: "any",
1943
- ignoreReadBeforeAssign: false
1944
- }
1945
- ],
1946
- "prefer-rest-params": "error",
1947
- "prefer-spread": "error",
1948
- "typescript/ban-ts-comment": "error",
1949
- "typescript/no-array-constructor": "error",
1950
- "typescript/no-duplicate-enum-values": "error",
1951
- "typescript/no-empty-object-type": "error",
1952
- "typescript/no-extra-non-null-assertion": "error",
1953
- "typescript/no-misused-new": "error",
1954
- "typescript/no-namespace": "error",
1955
- "typescript/no-non-null-asserted-optional-chain": "error",
1956
- "typescript/no-this-alias": "error",
1957
- "typescript/no-unnecessary-type-constraint": "error",
1958
- "typescript/no-unsafe-declaration-merging": "error",
1959
- "typescript/no-unsafe-function-type": "error",
1960
- "typescript/no-unused-expressions": [
1961
- "error",
1962
- {
1963
- allowShortCircuit: false,
1964
- allowTaggedTemplates: false,
1965
- allowTernary: false
1966
- }
1967
- ],
1968
- "typescript/no-wrapper-object-types": "error",
1969
- "typescript/prefer-as-const": "error",
1970
- "typescript/prefer-namespace-keyword": "error",
1971
- "typescript/triple-slash-reference": "error"
1972
- }
1973
- };
1974
-
1975
2224
  // src/roles/lint/presets/nest.json
1976
2225
  var nest_default = {
1977
2226
  plugins: ["typescript", "jest", "import", "unicorn", "oxc", "node"],
@@ -2311,6 +2560,7 @@ var nest_default = {
2311
2560
  "no-invalid-this": "no oxlint equivalent, and hosting does not help: the rule crashes on every file under oxlint's plugin API (sourceCode.getJSDocComment is not implemented). Its coverage moves to TypeScript's noImplicitThis, which the sentinel TypeScript role manages.",
2312
2561
  "no-multi-spaces": "formatting, and the formatter owns it. It moves to oxfmt, which is deliberately the LAST step of the migration, so nothing enforces it in between.",
2313
2562
  "no-octal": "no oxlint equivalent",
2563
+ "no-restricted-syntax": "no oxlint equivalent, and unlike the react tier this preset hosts no house-rule plugin, so architectural guards written as AST selectors are not enforced at all. They belong in sentinel's `hublo` plugin, next to the frontend ones: tell us which guards your service relies on. A per-module copy of a house rule drifts, which is the problem sentinel exists to remove.",
2314
2564
  "no-trailing-spaces": "formatting, and the formatter owns it. It moves to oxfmt, which is deliberately the LAST step of the migration, so nothing enforces it in between.",
2315
2565
  semi: "formatting, and the formatter owns it. It moves to oxfmt, which is deliberately the LAST step of the migration, so nothing enforces it in between.",
2316
2566
  "template-curly-spacing": "formatting, and the formatter owns it. It moves to oxfmt, which is deliberately the LAST step of the migration, so nothing enforces it in between.",
@@ -3264,10 +3514,51 @@ var react_default = {
3264
3514
  uncovered: {
3265
3515
  "import/order": "import ordering is FORMATTING, and the format role owns it: oxfmt's `sortImports` reproduces exactly these groups (builtin, external, internal, parent/index, sibling, alphabetical, blank line between) and sorts a file completely in ONE pass, because it rewrites the whole import block. A lint fixer cannot: it emits text edits over overlapping ranges, so this rule needed several runs to finish (five imports took four passes, measured) and sentinel deliberately runs autofix once. Enforced by `pnpm run format`, not by the linter, which is also why `eslint-plugin-import` is no longer hosted at all: its only other rule, no-duplicates, is native in oxlint.",
3266
3516
  "no-octal": "no oxlint equivalent",
3517
+ "no-restricted-syntax": "no oxlint equivalent as configuration, so the eight AST selectors this repo used are shipped as real rules in the local `hublo` plugin instead, which this tier hosts (`../plugins/hublo.js`). Any selector NOT in that plugin is not enforced here; propose it and it joins the plugin rather than a per-module copy.",
3267
3518
  semi: "formatting, and the formatter owns it. It moves to oxfmt, which is deliberately the LAST step of the migration, so nothing enforces it in between."
3268
3519
  }
3269
3520
  };
3270
3521
 
3522
+ // src/roles/lint/presets/shared.json
3523
+ var shared_default = {
3524
+ rules: {
3525
+ "no-var": "error",
3526
+ "prefer-const": [
3527
+ "error",
3528
+ {
3529
+ destructuring: "any",
3530
+ ignoreReadBeforeAssign: false
3531
+ }
3532
+ ],
3533
+ "prefer-rest-params": "error",
3534
+ "prefer-spread": "error",
3535
+ "typescript/ban-ts-comment": "error",
3536
+ "typescript/no-array-constructor": "error",
3537
+ "typescript/no-duplicate-enum-values": "error",
3538
+ "typescript/no-empty-object-type": "error",
3539
+ "typescript/no-extra-non-null-assertion": "error",
3540
+ "typescript/no-misused-new": "error",
3541
+ "typescript/no-namespace": "error",
3542
+ "typescript/no-non-null-asserted-optional-chain": "error",
3543
+ "typescript/no-this-alias": "error",
3544
+ "typescript/no-unnecessary-type-constraint": "error",
3545
+ "typescript/no-unsafe-declaration-merging": "error",
3546
+ "typescript/no-unsafe-function-type": "error",
3547
+ "typescript/no-unused-expressions": [
3548
+ "error",
3549
+ {
3550
+ allowShortCircuit: false,
3551
+ allowTaggedTemplates: false,
3552
+ allowTernary: false
3553
+ }
3554
+ ],
3555
+ "typescript/no-wrapper-object-types": "error",
3556
+ "typescript/prefer-as-const": "error",
3557
+ "typescript/prefer-namespace-keyword": "error",
3558
+ "typescript/triple-slash-reference": "error"
3559
+ }
3560
+ };
3561
+
3271
3562
  // src/roles/lint/presets/svelte.json
3272
3563
  var svelte_default = {
3273
3564
  plugins: ["typescript", "oxc"],
@@ -3606,7 +3897,7 @@ var tools_default = {
3606
3897
  };
3607
3898
 
3608
3899
  // src/roles/lint/preset-data.ts
3609
- var BASE_RULES = base_default2.rules;
3900
+ var SHARED_RULES = shared_default.rules;
3610
3901
  var PRESET_FILES = {
3611
3902
  nest: nest_default,
3612
3903
  node: node_default,
@@ -3628,15 +3919,18 @@ function hasLintPreset(preset) {
3628
3919
  function presetFile(preset) {
3629
3920
  return VARIANT_FILES[preset] ?? PRESET_FILES[preset] ?? node_default;
3630
3921
  }
3631
- function baseRules() {
3632
- return BASE_RULES;
3922
+ function sharedRules() {
3923
+ return SHARED_RULES;
3633
3924
  }
3634
3925
  function presetRules(preset) {
3635
3926
  return presetFile(preset).rules;
3636
3927
  }
3928
+ function uncoveredFor(preset) {
3929
+ return Object.entries(presetFile(preset).uncovered ?? {}).map(([rule, reason]) => ({ rule, reason })).sort((left, right) => left.rule.localeCompare(right.rule));
3930
+ }
3637
3931
 
3638
3932
  // src/roles/lint/rule-data.ts
3639
- var baseRules2 = baseRules();
3933
+ var sharedRules2 = sharedRules();
3640
3934
  function isDecision(value) {
3641
3935
  return typeof value === "object" && value !== null && !Array.isArray(value);
3642
3936
  }
@@ -3663,7 +3957,7 @@ function validate(preset, rules) {
3663
3957
  }
3664
3958
  }
3665
3959
  function layered(preset) {
3666
- const base = baseRules();
3960
+ const base = sharedRules();
3667
3961
  const layer = presetRules(preset);
3668
3962
  validate("base", base);
3669
3963
  validate(preset, layer);
@@ -3702,60 +3996,14 @@ function downgradedRulesFor(preset) {
3702
3996
  }
3703
3997
 
3704
3998
  // 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
-
3708
- // src/roles/lint/module-baseline.ts
3709
- var LINT_BASELINE_FILE = ".oxlintrc.baseline.json";
3710
- var LINT_MEASURE_FILE = ".oxlintrc.measure.json";
3711
- var LINT_BASELINE_SPECIFIER = `./${LINT_BASELINE_FILE}`;
3712
- function holdsFrom(errorCounts) {
3713
- return [...errorCounts].map(([rule, count]) => ({ rule, count })).sort((left, right) => right.count - left.count || left.rule.localeCompare(right.rule));
3714
- }
3715
- function renderBaseline(holds, version) {
3716
- const entries = holds.map(({ rule }) => ` ${JSON.stringify(rule)}: "warn"`).join(",\n");
3717
- return [
3718
- "{",
3719
- ` // Written by @hublo/sentinel@${version}. Do not edit by hand: \`sentinel --init --lint\``,
3720
- " // regenerates it from a fresh measurement, and a rule you have since fixed drops out",
3721
- " // and returns to `error` on its own.",
3722
- " //",
3723
- " // These rules are held at `warn` because this module ALREADY violated them when it",
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.",
3733
- ' "rules": {',
3734
- entries,
3735
- " }",
3736
- "}",
3737
- ""
3738
- ].join("\n");
3739
- }
3740
- function extendsWithBaseline(current, hasHolds) {
3741
- const withoutBaseline = current.filter((entry) => entry !== LINT_BASELINE_SPECIFIER);
3742
- return hasHolds ? [...withoutBaseline, LINT_BASELINE_SPECIFIER] : withoutBaseline;
3743
- }
3744
- function describeHolds(holds) {
3745
- if (holds.length === 0) return "";
3746
- const total = holds.reduce((sum, hold) => sum + hold.count, 0);
3747
- const listed = holds.slice(0, 5).map(({ rule, count }) => `${rule} (${count})`).join(", ");
3748
- const rest = holds.length > 5 ? `, and ${holds.length - 5} more` : "";
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`;
3750
- }
3751
-
3752
- // src/roles/lint/extra-layers.ts
3999
+ import { existsSync as existsSync14, readFileSync as readFileSync13 } from "fs";
4000
+ import { isAbsolute as isAbsolute2, join as join17, resolve as resolve3 } from "path";
3753
4001
  function ruleCount(cwd, specifier) {
3754
4002
  const path = isAbsolute2(specifier) ? specifier : resolve3(cwd, specifier);
3755
- if (!existsSync13(path)) return void 0;
4003
+ if (!existsSync14(path)) return void 0;
3756
4004
  try {
3757
4005
  const parsed = parseJsonc(
3758
- readFileSync12(path, "utf8"),
4006
+ readFileSync13(path, "utf8"),
3759
4007
  specifier
3760
4008
  );
3761
4009
  return Object.keys(parsed.rules ?? {}).length;
@@ -3775,7 +4023,7 @@ function extraLayers(cwd, extendsList) {
3775
4023
  function committedExtendsList(cwd, configFile) {
3776
4024
  try {
3777
4025
  const parsed = parseJsonc(
3778
- readFileSync12(join15(cwd, configFile), "utf8"),
4026
+ readFileSync13(join17(cwd, configFile), "utf8"),
3779
4027
  configFile
3780
4028
  );
3781
4029
  return Array.isArray(parsed.extends) ? parsed.extends.filter((entry) => typeof entry === "string") : [];
@@ -3834,8 +4082,8 @@ function errorCountsByConfigRule(stdout) {
3834
4082
  }
3835
4083
 
3836
4084
  // src/core/config/read-adoption.ts
3837
- import { existsSync as existsSync15, readFileSync as readFileSync14 } from "fs";
3838
- import { join as join17 } from "path";
4085
+ import { existsSync as existsSync16, readFileSync as readFileSync15 } from "fs";
4086
+ import { join as join19 } from "path";
3839
4087
 
3840
4088
  // src/core/config/owned-keys.ts
3841
4089
  function presetOwnedKeys(config, permitted, presetSets) {
@@ -3850,12 +4098,12 @@ function localOnlyKeys(config, permitted, presetSets) {
3850
4098
  }
3851
4099
 
3852
4100
  // src/core/config/resolve-config-target.ts
3853
- import { existsSync as existsSync14, readFileSync as readFileSync13 } from "fs";
3854
- import { join as join16 } from "path";
4101
+ import { existsSync as existsSync15, readFileSync as readFileSync14 } from "fs";
4102
+ import { join as join18 } from "path";
3855
4103
  function readExtends(absolutePath) {
3856
4104
  let parsed;
3857
4105
  try {
3858
- parsed = parseJsonc(readFileSync13(absolutePath, "utf8"), absolutePath);
4106
+ parsed = parseJsonc(readFileSync14(absolutePath, "utf8"), absolutePath);
3859
4107
  } catch {
3860
4108
  return [];
3861
4109
  }
@@ -3869,8 +4117,8 @@ function resolveConfigTarget(moduleDir, { candidates, markers, fallback }) {
3869
4117
  let existing;
3870
4118
  let existingExtendsSomething = false;
3871
4119
  for (const candidate of candidates) {
3872
- const absolutePath = join16(moduleDir, candidate);
3873
- if (!existsSync14(absolutePath)) continue;
4120
+ const absolutePath = join18(moduleDir, candidate);
4121
+ if (!existsSync15(absolutePath)) continue;
3874
4122
  const chain = readExtends(absolutePath);
3875
4123
  if (existing === void 0) {
3876
4124
  existing = candidate;
@@ -3903,12 +4151,12 @@ var NOT_ADOPTED3 = (configFile, unreadable = null) => ({
3903
4151
  });
3904
4152
  function readAdoption(cwd, options) {
3905
4153
  const target = resolveConfigTarget(cwd, options);
3906
- if (target.reason === "none" || !existsSync15(join17(cwd, target.path))) {
4154
+ if (target.reason === "none" || !existsSync16(join19(cwd, target.path))) {
3907
4155
  return NOT_ADOPTED3(target.reason === "none" ? null : target.path);
3908
4156
  }
3909
4157
  let parsed;
3910
4158
  try {
3911
- parsed = parseJsonc(readFileSync14(join17(cwd, target.path), "utf8"), target.path);
4159
+ parsed = parseJsonc(readFileSync15(join19(cwd, target.path), "utf8"), target.path);
3912
4160
  } catch (error) {
3913
4161
  const reason = error instanceof Error ? error.message : String(error);
3914
4162
  return NOT_ADOPTED3(target.path, `${target.path} could not be parsed (${reason})`);
@@ -3942,9 +4190,9 @@ function readLintAdoption(cwd) {
3942
4190
  }
3943
4191
 
3944
4192
  // src/roles/lint/resolve-oxlint.ts
3945
- import { existsSync as existsSync16 } from "fs";
4193
+ import { existsSync as existsSync17 } from "fs";
3946
4194
  import { createRequire as createRequire3 } from "module";
3947
- import { delimiter as delimiter2, dirname as dirname5, join as join18 } from "path";
4195
+ import { delimiter as delimiter2, dirname as dirname5, join as join20 } from "path";
3948
4196
  import { fileURLToPath as fileURLToPath2 } from "url";
3949
4197
  var PACKAGE_OF = {
3950
4198
  oxlint: "oxlint",
@@ -3969,32 +4217,31 @@ function tsgolintShim(cwd) {
3969
4217
  for (const owner of ["oxlint-tsgolint", "oxlint"]) {
3970
4218
  try {
3971
4219
  const packageDir = dirname5(require3.resolve(`${owner}/package.json`));
3972
- candidates.push(join18(packageDir, "node_modules", ".bin", "tsgolint"));
3973
- candidates.push(join18(packageDir, "..", ".bin", "tsgolint"));
4220
+ candidates.push(join20(packageDir, "node_modules", ".bin", "tsgolint"));
4221
+ candidates.push(join20(packageDir, "..", ".bin", "tsgolint"));
3974
4222
  } catch {
3975
4223
  }
3976
4224
  }
3977
4225
  try {
3978
4226
  const ownRoot = dirname5(require3.resolve("@hublo/sentinel/package.json"));
3979
- candidates.push(join18(ownRoot, "node_modules", ".bin", "tsgolint"));
4227
+ candidates.push(join20(ownRoot, "node_modules", ".bin", "tsgolint"));
3980
4228
  } catch {
3981
4229
  candidates.push(resolveBin(dirname5(fileURLToPath2(import.meta.url)), "tsgolint") ?? "");
3982
4230
  }
3983
- return candidates.find((candidate) => candidate !== "" && existsSync16(candidate));
4231
+ return candidates.find((candidate) => candidate !== "" && existsSync17(candidate));
3984
4232
  }
3985
4233
  function oxlintSearchPath(cwd) {
3986
4234
  return binSearchPath(cwd);
3987
4235
  }
3988
4236
 
3989
4237
  // src/roles/lint/adapters/oxlint/plan.ts
3990
- import { existsSync as existsSync18, readFileSync as readFileSync17 } from "fs";
3991
- import { join as join21 } from "path";
4238
+ import { existsSync as existsSync19, readFileSync as readFileSync18 } from "fs";
4239
+ import { join as join23 } from "path";
3992
4240
 
3993
4241
  // src/roles/lint/eslint-ignores.ts
3994
- import { existsSync as existsSync17, readFileSync as readFileSync15 } from "fs";
3995
- import { join as join19 } from "path";
3996
- var IGNORE_BLOCKS = [/\bignores\s*:\s*\[([^\]]*)\]/g, /\bglobalIgnores\s*\(\s*\[([^\]]*)\]/g];
3997
- var STRING_LITERAL = /['"`]([^'"`]+)['"`]/g;
4242
+ import { existsSync as existsSync18, readFileSync as readFileSync16 } from "fs";
4243
+ import { join as join21 } from "path";
4244
+ import { parseSync as parseSync2 } from "oxc-parser";
3998
4245
  var OPAQUE_SOURCE = /\b(includeIgnoreFile)\s*\([^)]*\)/g;
3999
4246
  function toOxlintPattern(pattern) {
4000
4247
  return pattern.replace(/^\.\//, "");
@@ -4005,24 +4252,71 @@ function readRootEslintIgnores(root) {
4005
4252
  return { patterns: patterns.filter((pattern) => pattern.startsWith("**/")), unresolved };
4006
4253
  }
4007
4254
  function readEslintIgnores(cwd) {
4008
- const config = ESLINT_CONFIG_FILES.map((name) => join19(cwd, name)).find((path) => existsSync17(path));
4255
+ const config = ESLINT_CONFIG_FILES.map((name) => join21(cwd, name)).find((path) => existsSync18(path));
4009
4256
  if (!config) return { patterns: [], unresolved: [] };
4010
4257
  let source;
4011
4258
  try {
4012
- source = readFileSync15(config, "utf8");
4259
+ source = readFileSync16(config, "utf8");
4013
4260
  } catch {
4014
4261
  return { patterns: [], unresolved: [] };
4015
4262
  }
4016
4263
  const patterns = /* @__PURE__ */ new Set();
4017
- for (const block of IGNORE_BLOCKS) {
4018
- for (const [, body] of source.matchAll(block)) {
4019
- for (const [, literal] of (body ?? "").matchAll(STRING_LITERAL)) {
4020
- if (literal) patterns.add(toOxlintPattern(literal));
4264
+ const unresolved = new Set([...source.matchAll(OPAQUE_SOURCE)].map((match) => match[0]));
4265
+ const { program, errors } = parseSync2(config, source, { sourceType: "module" });
4266
+ if (errors.length > 0) {
4267
+ return { patterns: [], unresolved: [`${config} could not be parsed`] };
4268
+ }
4269
+ walk(program, (node) => {
4270
+ if (node.type === "CallExpression" && calleeName(node) === "globalIgnores") {
4271
+ for (const literal of stringLiterals(firstArrayArgument(node))) {
4272
+ patterns.add(toOxlintPattern(literal));
4021
4273
  }
4274
+ return;
4275
+ }
4276
+ if (node.type !== "ObjectExpression") return;
4277
+ const keys = objectKeys(node);
4278
+ if (!keys.includes("ignores")) return;
4279
+ const values = arrayValues(node, "ignores");
4280
+ if (keys.length > 1) {
4281
+ unresolved.add(
4282
+ `ignores scoped to one config block (with ${keys.filter((k) => k !== "ignores").join(", ")}): ${values.join(", ")} \u2014 exempt from those RULES, not excluded from linting`
4283
+ );
4284
+ return;
4022
4285
  }
4286
+ for (const literal of values) patterns.add(toOxlintPattern(literal));
4287
+ });
4288
+ return { patterns: [...patterns], unresolved: [...unresolved] };
4289
+ }
4290
+ function walk(node, visit) {
4291
+ if (!node || typeof node !== "object") return;
4292
+ const current = node;
4293
+ if (typeof current.type === "string") visit(current);
4294
+ for (const key of Object.keys(current)) {
4295
+ const value = current[key];
4296
+ if (Array.isArray(value)) for (const entry of value) walk(entry, visit);
4297
+ else if (value && typeof value === "object") walk(value, visit);
4023
4298
  }
4024
- const unresolved = [...source.matchAll(OPAQUE_SOURCE)].map((match) => match[0]);
4025
- return { patterns: [...patterns], unresolved: [...new Set(unresolved)] };
4299
+ }
4300
+ function calleeName(node) {
4301
+ const callee = node.callee;
4302
+ return callee?.type === "Identifier" ? callee.name : void 0;
4303
+ }
4304
+ function firstArrayArgument(node) {
4305
+ const args = node.arguments ?? [];
4306
+ return args.find((argument) => argument?.type === "ArrayExpression");
4307
+ }
4308
+ function stringLiterals(array) {
4309
+ const elements = array?.elements ?? [];
4310
+ return elements.filter((element) => element?.type === "Literal" && typeof element.value === "string").map((element) => element.value);
4311
+ }
4312
+ function objectKeys(node) {
4313
+ const properties = node.properties ?? [];
4314
+ return properties.filter((property) => property?.type === "Property" && !property.computed).map((property) => property.key?.name ?? String(property.key?.value ?? "")).filter(Boolean);
4315
+ }
4316
+ function arrayValues(node, name) {
4317
+ const properties = node.properties ?? [];
4318
+ const found = properties.find((property) => property.key?.name === name);
4319
+ return stringLiterals(found?.value);
4026
4320
  }
4027
4321
 
4028
4322
  // src/roles/lint/lint-script.ts
@@ -4063,8 +4357,8 @@ function lintPresetFor(preset) {
4063
4357
  }
4064
4358
 
4065
4359
  // src/roles/lint/rename-suppressions.ts
4066
- import { readdirSync as readdirSync2, readFileSync as readFileSync16, statSync } from "fs";
4067
- import { join as join20, relative as relative2 } from "path";
4360
+ import { readdirSync as readdirSync2, readFileSync as readFileSync17, statSync as statSync2 } from "fs";
4361
+ import { join as join22, relative as relative3 } from "path";
4068
4362
  var SOURCE_EXTENSIONS = [
4069
4363
  ".ts",
4070
4364
  ".tsx",
@@ -4111,10 +4405,10 @@ function* sourceFiles(dir) {
4111
4405
  return;
4112
4406
  }
4113
4407
  for (const entry of entries) {
4114
- const full = join20(dir, entry);
4408
+ const full = join22(dir, entry);
4115
4409
  let isDirectory;
4116
4410
  try {
4117
- isDirectory = statSync(full).isDirectory();
4411
+ isDirectory = statSync2(full).isDirectory();
4118
4412
  } catch {
4119
4413
  continue;
4120
4414
  }
@@ -4131,7 +4425,7 @@ function findSuppressionRenames(cwd, renames) {
4131
4425
  for (const file of sourceFiles(cwd)) {
4132
4426
  let content;
4133
4427
  try {
4134
- content = readFileSync16(file, "utf8");
4428
+ content = readFileSync17(file, "utf8");
4135
4429
  } catch {
4136
4430
  continue;
4137
4431
  }
@@ -4155,7 +4449,7 @@ function findSuppressionRenames(cwd, renames) {
4155
4449
  }
4156
4450
  if (hits.length === 0) return;
4157
4451
  found.push({
4158
- file: relative2(cwd, file),
4452
+ file: relative3(cwd, file),
4159
4453
  line: index + 1,
4160
4454
  from: line,
4161
4455
  to: line.replace(rules, renamed.join(", ")),
@@ -4259,7 +4553,7 @@ function plan2(context) {
4259
4553
  keys: removableDeps
4260
4554
  });
4261
4555
  }
4262
- const eslintConfigs = ESLINT_CONFIG_FILES.filter((name) => existsSync18(join21(context.cwd, name)));
4556
+ const eslintConfigs = ESLINT_CONFIG_FILES.filter((name) => existsSync19(join23(context.cwd, name)));
4263
4557
  for (const name of eslintConfigs) operations.push({ kind: "delete", path: name });
4264
4558
  operations.push(
4265
4559
  ...nxTargetOperations({
@@ -4357,7 +4651,7 @@ function lintScripts(cwd) {
4357
4651
  function committedExtends(cwd) {
4358
4652
  try {
4359
4653
  const parsed = parseJsonc(
4360
- readFileSync17(join21(cwd, LINT_CONFIG_FILE), "utf8"),
4654
+ readFileSync18(join23(cwd, LINT_CONFIG_FILE), "utf8"),
4361
4655
  LINT_CONFIG_FILE
4362
4656
  );
4363
4657
  return Array.isArray(parsed.extends) ? parsed.extends.filter((entry) => typeof entry === "string") : [];
@@ -4368,7 +4662,7 @@ function committedExtends(cwd) {
4368
4662
  function committedIgnorePatterns(cwd) {
4369
4663
  try {
4370
4664
  const parsed = parseJsonc(
4371
- readFileSync17(join21(cwd, LINT_CONFIG_FILE), "utf8"),
4665
+ readFileSync18(join23(cwd, LINT_CONFIG_FILE), "utf8"),
4372
4666
  LINT_CONFIG_FILE
4373
4667
  );
4374
4668
  return Array.isArray(parsed.ignorePatterns) ? parsed.ignorePatterns.filter((entry) => typeof entry === "string") : [];
@@ -4380,7 +4674,7 @@ function moduleEslintDependencies(cwd) {
4380
4674
  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/");
4381
4675
  let manifest;
4382
4676
  try {
4383
- manifest = JSON.parse(readFileSync17(join21(cwd, "package.json"), "utf8"));
4677
+ manifest = JSON.parse(readFileSync18(join23(cwd, "package.json"), "utf8"));
4384
4678
  } catch {
4385
4679
  return [];
4386
4680
  }
@@ -4445,17 +4739,31 @@ var OxlintAdapter = class extends BaseAdapter {
4445
4739
  return presetOfVariant(presetNameFromPath(readLintAdoption(cwd).preset));
4446
4740
  }
4447
4741
  /**
4448
- * The autofix pass. Output is captured rather than streamed: the fix run is noise, and the
4449
- * verification run that follows is the one a developer reads.
4742
+ * The autofix pass, and what it did.
4743
+ *
4744
+ * Output is captured rather than streamed: the fix run is noise, and the verification run
4745
+ * that follows is the one a developer reads. But captured is not the same as discarded, and
4746
+ * it used to be discarded, which is why a pass that never ran looked exactly like one that
4747
+ * had nothing to do.
4748
+ *
4749
+ * The exit status cannot be the signal, and neither can the stream. Measured on oxlint: a
4750
+ * healthy run on a module with remaining violations and a run whose `extends` path resolves
4751
+ * nowhere BOTH exit 1 and both write to stdout. One module was checked and one was not, and
4752
+ * nothing in the process result separates them, so the only signal is what the tool said.
4450
4753
  */
4451
4754
  fixPass(ctx, oxlint, env) {
4452
- const result = spawnSync5(oxlint, ["-c", LINT_CONFIG_FILE, "--fix", "."], {
4755
+ const before = snapshotFiles(ctx.cwd, LINTABLE_EXTENSIONS);
4756
+ const result = spawnSync4(oxlint, ["-c", LINT_CONFIG_FILE, "--fix", "."], {
4453
4757
  cwd: ctx.cwd,
4454
4758
  encoding: "utf8",
4455
4759
  env,
4456
4760
  maxBuffer: CAPTURE_MAX_BUFFER
4457
4761
  });
4458
- return result.status ?? 1;
4762
+ if (result.error) return { changed: [], failure: result.error.message };
4763
+ const failure = readFailure(`${result.stdout ?? ""}
4764
+ ${result.stderr ?? ""}`);
4765
+ if (failure !== void 0) return { changed: [], failure };
4766
+ return { changed: changedSince(ctx.cwd, before, LINTABLE_EXTENSIONS), failure: void 0 };
4459
4767
  }
4460
4768
  /** The module's current `lint` script, so adoption replaces only the eslint part of it. */
4461
4769
  /**
@@ -4478,7 +4786,8 @@ var OxlintAdapter = class extends BaseAdapter {
4478
4786
  const oxlint = resolveOxlint(ctx.cwd);
4479
4787
  if (!oxlint) return;
4480
4788
  const env = { ...process.env, PATH: oxlintPath(ctx.cwd, process.env) };
4481
- this.fixPass(ctx, oxlint, env);
4789
+ process.stderr.write(` ${describeFixOutcome(this.fixPass(ctx, oxlint, env), "lint")}
4790
+ `);
4482
4791
  this.writeModuleBaseline(ctx, oxlint, env);
4483
4792
  }
4484
4793
  /**
@@ -4499,20 +4808,20 @@ var OxlintAdapter = class extends BaseAdapter {
4499
4808
  * build the developer can see, rather than a silent half-adoption they cannot.
4500
4809
  */
4501
4810
  writeModuleBaseline(ctx, oxlint, env) {
4502
- const configPath = join22(ctx.cwd, LINT_CONFIG_FILE);
4503
- const baselinePath = join22(ctx.cwd, LINT_BASELINE_FILE);
4504
- if (!existsSync19(configPath)) return;
4811
+ const configPath = join24(ctx.cwd, LINT_CONFIG_FILE);
4812
+ const baselinePath = join24(ctx.cwd, LINT_BASELINE_FILE);
4813
+ if (!existsSync20(configPath)) return;
4505
4814
  let config;
4506
4815
  try {
4507
4816
  config = parseJsonc(
4508
- readFileSync18(configPath, "utf8"),
4817
+ readFileSync19(configPath, "utf8"),
4509
4818
  LINT_CONFIG_FILE
4510
4819
  );
4511
4820
  } catch {
4512
4821
  return;
4513
4822
  }
4514
4823
  const current = Array.isArray(config.extends) ? config.extends : [];
4515
- const measurePath = join22(ctx.cwd, LINT_MEASURE_FILE);
4824
+ const measurePath = join24(ctx.cwd, LINT_MEASURE_FILE);
4516
4825
  let measured;
4517
4826
  try {
4518
4827
  writeFileSync2(
@@ -4520,7 +4829,7 @@ var OxlintAdapter = class extends BaseAdapter {
4520
4829
  `${JSON.stringify({ ...config, extends: extendsWithBaseline(current, false) }, null, 2)}
4521
4830
  `
4522
4831
  );
4523
- measured = spawnSync5(
4832
+ measured = spawnSync4(
4524
4833
  oxlint,
4525
4834
  [
4526
4835
  "-c",
@@ -4560,7 +4869,7 @@ var OxlintAdapter = class extends BaseAdapter {
4560
4869
  }
4561
4870
  }
4562
4871
  async run(ctx) {
4563
- if (!existsSync19(join22(ctx.cwd, LINT_CONFIG_FILE))) {
4872
+ if (!existsSync20(join24(ctx.cwd, LINT_CONFIG_FILE))) {
4564
4873
  process.stderr.write(
4565
4874
  `sentinel lint(oxlint): no ${LINT_CONFIG_FILE} in this module; run \`sentinel --init --lint\` to adopt.
4566
4875
  `
@@ -4605,9 +4914,10 @@ var OxlintAdapter = class extends BaseAdapter {
4605
4914
  valueFlags: OXLINT_VALUE_FLAGS
4606
4915
  });
4607
4916
  const targets = paths.length > 0 ? paths : ["."];
4917
+ const emptyScope = scopeFlags(paths);
4608
4918
  const lint = (extra) => {
4609
- const passed = [...extra, ...passedOptions];
4610
- const result = spawnSync5(oxlint, ["-c", LINT_CONFIG_FILE, ...passed, ...targets], {
4919
+ const passed = [...extra, ...emptyScope, ...passedOptions];
4920
+ const result = spawnSync4(oxlint, ["-c", LINT_CONFIG_FILE, ...passed, ...targets], {
4611
4921
  cwd: ctx.cwd,
4612
4922
  stdio: "inherit",
4613
4923
  env
@@ -4645,6 +4955,33 @@ var OxlintAdapter = class extends BaseAdapter {
4645
4955
  rule: entry.rule,
4646
4956
  reason: entry.reason
4647
4957
  })),
4958
+ // A FOURTH state, and the only one that was declared and never reported: rules oxlint
4959
+ // cannot run at all, so they can be neither enforced nor disabled. Each preset has
4960
+ // carried this list since the Svelte work, and nothing read it outside a test.
4961
+ //
4962
+ // The gap that made it matter: `no-restricted-syntax` has no oxlint equivalent, so two
4963
+ // architectural guards a Nest service relied on vanished at adoption with nothing said.
4964
+ // The adopter found it himself and rebuilt them as a local plugin. The next one might
4965
+ // not, which is the whole argument for saying it out loud.
4966
+ uncovered: uncoveredFor(presetVariant(ctx.preset, ctx.cwd)).map((entry) => ({
4967
+ rule: entry.rule,
4968
+ reason: entry.reason
4969
+ })),
4970
+ // What this module HOLDS: the rules its own baseline lowers to `warn`.
4971
+ //
4972
+ // Distinct from `downgraded` just above, which is the PRESET's doing and identical for
4973
+ // every module on it. These are this module's own debt, and leaving them out was a real
4974
+ // gap: `--inspect` could tell you the preset holds 6 rules while saying nothing about
4975
+ // the 3 your module holds on top, so nobody could see their own debt without opening a
4976
+ // file that says "do not edit by hand".
4977
+ //
4978
+ // Listed, not counted. The list is free — it is the committed file — while a count means
4979
+ // running oxlint, which this verb deliberately does not do (that is what keeps a
4980
+ // 441-module sweep at ~8s per role). `--run --lint --json` carries the per-rule counts.
4981
+ held: heldRules(ctx.cwd).map((rule) => ({
4982
+ rule,
4983
+ reason: `held at \`warn\` by this module's ${LINT_BASELINE_FILE}, because it already violated it when it adopted. \`--run --lint --json\` counts what is left.`
4984
+ })),
4648
4985
  // What this module enforces BEYOND the preset. Not drift, and not covered by anything
4649
4986
  // else here: the stub still holds only `extends` and `ignorePatterns`, so a module with
4650
4987
  // a team layer reports `conformant=true drift=[]` and used to say nothing at all about
@@ -4680,7 +5017,7 @@ var OxlintAdapter = class extends BaseAdapter {
4680
5017
  let stub;
4681
5018
  try {
4682
5019
  stub = parseJsonc(
4683
- readFileSync18(join22(cwd, LINT_CONFIG_FILE), "utf8"),
5020
+ readFileSync19(join24(cwd, LINT_CONFIG_FILE), "utf8"),
4684
5021
  LINT_CONFIG_FILE
4685
5022
  );
4686
5023
  } catch {
@@ -4690,7 +5027,7 @@ var OxlintAdapter = class extends BaseAdapter {
4690
5027
  for (const entry of stub.extends ?? []) {
4691
5028
  try {
4692
5029
  const preset = parseJsonc(
4693
- readFileSync18(join22(cwd, entry), "utf8"),
5030
+ readFileSync19(join24(cwd, entry), "utf8"),
4694
5031
  entry
4695
5032
  );
4696
5033
  for (const rule of Object.keys(preset.rules ?? {})) names.add(rule);
@@ -4707,7 +5044,7 @@ var OxlintAdapter = class extends BaseAdapter {
4707
5044
  const oxlint = resolveOxlint(ctx.cwd);
4708
5045
  if (!oxlint) return { ok: false, code: 1, metrics: { error: "oxlint not found" } };
4709
5046
  const typeAware = canRunTypeAware(ctx.cwd) ? ["--type-aware"] : [];
4710
- const result = spawnSync5(oxlint, ["-c", LINT_CONFIG_FILE, ...typeAware, "-f", "json", "."], {
5047
+ const result = spawnSync4(oxlint, ["-c", LINT_CONFIG_FILE, ...typeAware, "-f", "json", "."], {
4711
5048
  cwd: ctx.cwd,
4712
5049
  encoding: "utf8",
4713
5050
  env: { ...process.env, PATH: oxlintPath(ctx.cwd, process.env) },
@@ -4760,14 +5097,14 @@ var OxlintAdapter = class extends BaseAdapter {
4760
5097
  let parsed;
4761
5098
  try {
4762
5099
  parsed = parseJsonc(
4763
- readFileSync18(join22(cwd, LINT_CONFIG_FILE), "utf8"),
5100
+ readFileSync19(join24(cwd, LINT_CONFIG_FILE), "utf8"),
4764
5101
  LINT_CONFIG_FILE
4765
5102
  );
4766
5103
  } catch {
4767
5104
  return void 0;
4768
5105
  }
4769
5106
  const targets = Array.isArray(parsed.extends) ? parsed.extends.filter((entry) => typeof entry === "string") : typeof parsed.extends === "string" ? [parsed.extends] : [];
4770
- return targets.find((target) => !existsSync19(join22(cwd, target)));
5107
+ return targets.find((target) => !existsSync20(join24(cwd, target)));
4771
5108
  }
4772
5109
  /** Announce what is not enforced, so reduced coverage is never silent. */
4773
5110
  announceDisabled(preset) {
@@ -4798,36 +5135,10 @@ function registerLint() {
4798
5135
  }
4799
5136
 
4800
5137
  // src/roles/typescript/adapters/tsc/tsc.adapter.ts
4801
- import { spawnSync as spawnSync6 } from "child_process";
4802
- import { existsSync as existsSync20, readFileSync as readFileSync20 } from "fs";
5138
+ import { spawnSync as spawnSync5 } from "child_process";
5139
+ import { existsSync as existsSync21, readFileSync as readFileSync21 } from "fs";
4803
5140
  import { createRequire as createRequire4 } from "module";
4804
- import { join as join24 } from "path";
4805
-
4806
- // src/roles/typescript/presets/base.json
4807
- var base_default3 = {
4808
- $schema: "https://json.schemastore.org/tsconfig",
4809
- compilerOptions: {
4810
- strict: true,
4811
- noFallthroughCasesInSwitch: true,
4812
- forceConsistentCasingInFileNames: true,
4813
- esModuleInterop: true,
4814
- skipLibCheck: true
4815
- },
4816
- deferred: {
4817
- noImplicitAny: {
4818
- phase: 2,
4819
- reason: "the implicit-any migration (TS70xx)"
4820
- },
4821
- noUnusedLocals: {
4822
- phase: 2,
4823
- reason: "unused-local cleanup (TS6133)"
4824
- },
4825
- noUnusedParameters: {
4826
- phase: 2,
4827
- reason: "unused-parameter cleanup (TS6133)"
4828
- }
4829
- }
4830
- };
5141
+ import { join as join26 } from "path";
4831
5142
 
4832
5143
  // src/roles/typescript/presets/nest.json
4833
5144
  var nest_default2 = {
@@ -4878,15 +5189,41 @@ var react_default2 = {
4878
5189
  }
4879
5190
  };
4880
5191
 
5192
+ // src/roles/typescript/presets/shared.json
5193
+ var shared_default2 = {
5194
+ $schema: "https://json.schemastore.org/tsconfig",
5195
+ compilerOptions: {
5196
+ strict: true,
5197
+ noFallthroughCasesInSwitch: true,
5198
+ forceConsistentCasingInFileNames: true,
5199
+ esModuleInterop: true,
5200
+ skipLibCheck: true
5201
+ },
5202
+ deferred: {
5203
+ noImplicitAny: {
5204
+ phase: 2,
5205
+ reason: "the implicit-any migration (TS70xx)"
5206
+ },
5207
+ noUnusedLocals: {
5208
+ phase: 2,
5209
+ reason: "unused-local cleanup (TS6133)"
5210
+ },
5211
+ noUnusedParameters: {
5212
+ phase: 2,
5213
+ reason: "unused-parameter cleanup (TS6133)"
5214
+ }
5215
+ }
5216
+ };
5217
+
4881
5218
  // src/roles/typescript/preset-data.ts
4882
- var BASE = base_default3;
5219
+ var SHARED = shared_default2;
4883
5220
  var LAYERS = {
4884
5221
  nest: nest_default2,
4885
5222
  node: node_default2,
4886
5223
  react: react_default2
4887
5224
  };
4888
5225
  var SHIPPED_PRESETS = Object.keys(LAYERS).sort();
4889
- var DEFERRED_RULES = Object.entries(BASE.deferred ?? {}).map(([rule, entry]) => ({ rule, phase: entry.phase, reason: entry.reason })).sort((left, right) => left.rule.localeCompare(right.rule));
5226
+ var DEFERRED_RULES = Object.entries(SHARED.deferred ?? {}).map(([rule, entry]) => ({ rule, phase: entry.phase, reason: entry.reason })).sort((left, right) => left.rule.localeCompare(right.rule));
4890
5227
  function validate2() {
4891
5228
  for (const { rule, phase, reason } of DEFERRED_RULES) {
4892
5229
  if (!Number.isInteger(phase) || phase < 2) {
@@ -4899,9 +5236,9 @@ function validate2() {
4899
5236
  `deferred rule "${rule}" has no reason; --inspect reports it and would say nothing`
4900
5237
  );
4901
5238
  }
4902
- if (rule in BASE.compilerOptions) {
5239
+ if (rule in SHARED.compilerOptions) {
4903
5240
  throw new Error(
4904
- `"${rule}" is deferred and also set by the base preset; it would be announced as not enforced while being enforced`
5241
+ `"${rule}" is deferred and also set by the shared layer; it would be announced as not enforced while being enforced`
4905
5242
  );
4906
5243
  }
4907
5244
  for (const [preset, layer] of Object.entries(LAYERS)) {
@@ -4921,8 +5258,8 @@ function tsPresetFor(preset) {
4921
5258
  const layer = LAYERS[preset];
4922
5259
  if (!layer) throw new Error(`no TypeScript preset ships for preset "${preset}"`);
4923
5260
  return {
4924
- ...BASE.$schema === void 0 ? {} : { $schema: BASE.$schema },
4925
- compilerOptions: { ...BASE.compilerOptions, ...layer.compilerOptions }
5261
+ ...SHARED.$schema === void 0 ? {} : { $schema: SHARED.$schema },
5262
+ compilerOptions: { ...SHARED.compilerOptions, ...layer.compilerOptions }
4926
5263
  };
4927
5264
  }
4928
5265
 
@@ -4982,8 +5319,8 @@ function readTsconfigAdoption(cwd) {
4982
5319
  }
4983
5320
 
4984
5321
  // src/roles/typescript/adapters/tsc/plan.ts
4985
- import { readFileSync as readFileSync19 } from "fs";
4986
- import { join as join23 } from "path";
5322
+ import { readFileSync as readFileSync20 } from "fs";
5323
+ import { join as join25 } from "path";
4987
5324
 
4988
5325
  // src/roles/typescript/typecheck-script.ts
4989
5326
  var SENTINEL_TYPECHECK_COMMAND = "sentinel --run --typescript";
@@ -5076,7 +5413,7 @@ function planAdoption(context) {
5076
5413
  };
5077
5414
  }
5078
5415
  const existing = parseJsonc(
5079
- readFileSync19(join23(context.cwd, target.path), "utf8"),
5416
+ readFileSync20(join25(context.cwd, target.path), "utf8"),
5080
5417
  target.path
5081
5418
  );
5082
5419
  const extendsChain = composeExtends(existing.extends, preset);
@@ -5214,7 +5551,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5214
5551
  let chain;
5215
5552
  try {
5216
5553
  const parsed = parseJsonc(
5217
- readFileSync20(join24(cwd, target.path), "utf8"),
5554
+ readFileSync21(join26(cwd, target.path), "utf8"),
5218
5555
  target.path
5219
5556
  );
5220
5557
  chain = parsed.extends;
@@ -5227,7 +5564,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5227
5564
  );
5228
5565
  if (preset === void 0) return void 0;
5229
5566
  try {
5230
- createRequire4(join24(cwd, "noop.js")).resolve(preset);
5567
+ createRequire4(join26(cwd, "noop.js")).resolve(preset);
5231
5568
  return void 0;
5232
5569
  } catch {
5233
5570
  return preset;
@@ -5268,7 +5605,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5268
5605
  return { ok: false, code: 1 };
5269
5606
  }
5270
5607
  if (options.length > 0) return this.runWithOptions(ctx, tsc, config);
5271
- const result = spawnSync6(tsc, ["-b", config], { cwd: ctx.cwd, stdio: "inherit" });
5608
+ const result = spawnSync5(tsc, ["-b", config], { cwd: ctx.cwd, stdio: "inherit" });
5272
5609
  if (result.error) {
5273
5610
  process.stderr.write(
5274
5611
  `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
@@ -5303,7 +5640,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5303
5640
  let worst = 0;
5304
5641
  for (const project of this.referencedProjects(ctx.cwd, config)) {
5305
5642
  const args = ["-p", project, "--noEmit", "--composite", "false", ...ctx.toolArgs ?? []];
5306
- const result = spawnSync6(tsc, args, { cwd: ctx.cwd, stdio: "inherit" });
5643
+ const result = spawnSync5(tsc, args, { cwd: ctx.cwd, stdio: "inherit" });
5307
5644
  if (result.error) {
5308
5645
  process.stderr.write(
5309
5646
  `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
@@ -5323,7 +5660,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5323
5660
  referencedProjects(cwd, config) {
5324
5661
  try {
5325
5662
  const parsed = parseJsonc(
5326
- readFileSync20(join24(cwd, config), "utf8"),
5663
+ readFileSync21(join26(cwd, config), "utf8"),
5327
5664
  config
5328
5665
  );
5329
5666
  const referenced = (parsed.references ?? []).map((reference) => reference.path).filter((path) => typeof path === "string" && path.length > 0);
@@ -5339,7 +5676,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5339
5676
  * check.
5340
5677
  */
5341
5678
  typecheckTarget(cwd) {
5342
- if (existsSync20(join24(cwd, "tsconfig.json"))) return "tsconfig.json";
5679
+ if (existsSync21(join26(cwd, "tsconfig.json"))) return "tsconfig.json";
5343
5680
  const target = resolveTsconfigTarget(cwd);
5344
5681
  return target.reason === "none" ? null : target.path;
5345
5682
  }
@@ -5392,7 +5729,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5392
5729
  return { ok: true, code: 0, metrics: _TscAdapter.NOTHING_TO_REPORT };
5393
5730
  }
5394
5731
  const tsc = resolveBin(ctx.cwd, "tsc") ?? "tsc";
5395
- const result = spawnSync6(tsc, ["-b", config], { cwd: ctx.cwd, encoding: "utf8" });
5732
+ const result = spawnSync5(tsc, ["-b", config], { cwd: ctx.cwd, encoding: "utf8" });
5396
5733
  if (result.error) {
5397
5734
  process.stderr.write(
5398
5735
  `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
@@ -5435,7 +5772,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5435
5772
  * errors in the output mean the rule must be on.
5436
5773
  */
5437
5774
  noImplicitAnyEnabled(cwd, tsc, config, mainOutput) {
5438
- const shown = spawnSync6(tsc, ["-p", config, "--showConfig"], { cwd, encoding: "utf8" });
5775
+ const shown = spawnSync5(tsc, ["-p", config, "--showConfig"], { cwd, encoding: "utf8" });
5439
5776
  if (shown.status === 0 && shown.stdout) {
5440
5777
  try {
5441
5778
  const co = parseJsonc(
@@ -5487,7 +5824,7 @@ function detectFramework(packageJson) {
5487
5824
  }
5488
5825
 
5489
5826
  // src/core/dispatch.ts
5490
- import { basename as basename2 } from "path";
5827
+ import { basename as basename3 } from "path";
5491
5828
 
5492
5829
  // src/shared/text.ts
5493
5830
  function ensureLines(current, lines) {
@@ -5543,9 +5880,16 @@ function replaceLines(current, replacements) {
5543
5880
  }
5544
5881
 
5545
5882
  // src/core/apply-plan.ts
5546
- import { existsSync as existsSync21, readFileSync as readFileSync21, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
5883
+ import { existsSync as existsSync22, readFileSync as readFileSync22, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
5547
5884
  import { resolve as resolve4, sep } from "path";
5548
5885
  import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
5886
+
5887
+ // src/shared/deep-merge.ts
5888
+ function isPlainObject(value) {
5889
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5890
+ }
5891
+
5892
+ // src/core/apply-plan.ts
5549
5893
  function resolveWithinRoot(cwd, relativePath) {
5550
5894
  const root = resolve4(cwd);
5551
5895
  const absolutePath = resolve4(root, relativePath);
@@ -5555,7 +5899,7 @@ function resolveWithinRoot(cwd, relativePath) {
5555
5899
  return absolutePath;
5556
5900
  }
5557
5901
  function readIfExists(absolutePath) {
5558
- return existsSync21(absolutePath) ? readFileSync21(absolutePath, "utf8") : void 0;
5902
+ return existsSync22(absolutePath) ? readFileSync22(absolutePath, "utf8") : void 0;
5559
5903
  }
5560
5904
  function* leaves(value, prefix = []) {
5561
5905
  for (const [key, keyValue] of Object.entries(value)) {
@@ -5673,8 +6017,8 @@ function applyPlan(cwd, plan3) {
5673
6017
  }
5674
6018
 
5675
6019
  // src/core/config/preset-evidence.ts
5676
- import { existsSync as existsSync22, readdirSync as readdirSync3, readFileSync as readFileSync22 } from "fs";
5677
- import { join as join25 } from "path";
6020
+ import { existsSync as existsSync23, readdirSync as readdirSync3, readFileSync as readFileSync23 } from "fs";
6021
+ import { join as join27 } from "path";
5678
6022
  var PATH_SIGNALS = [
5679
6023
  {
5680
6024
  preset: "nest",
@@ -5688,11 +6032,11 @@ var DEPENDENCY_SIGNALS = [
5688
6032
  { preset: "nest", pattern: /^@nestjs\// }
5689
6033
  ];
5690
6034
  function dependencyNames(cwd) {
5691
- const path = join25(cwd, "package.json");
5692
- if (!existsSync22(path)) return [];
6035
+ const path = join27(cwd, "package.json");
6036
+ if (!existsSync23(path)) return [];
5693
6037
  try {
5694
6038
  const manifest = parseJsonc(
5695
- readFileSync22(path, "utf8"),
6039
+ readFileSync23(path, "utf8"),
5696
6040
  path
5697
6041
  );
5698
6042
  return [
@@ -5715,7 +6059,7 @@ function declaresJsx(cwd) {
5715
6059
  for (const name of entries) {
5716
6060
  try {
5717
6061
  const config = parseJsonc(
5718
- readFileSync22(join25(cwd, name), "utf8"),
6062
+ readFileSync23(join27(cwd, name), "utf8"),
5719
6063
  name
5720
6064
  );
5721
6065
  if (config.compilerOptions?.jsx !== void 0) return true;
@@ -5812,13 +6156,26 @@ async function dispatch(opts) {
5812
6156
  const resolutionPreset = opts.preset ?? declaredPresetFor(opts.target, opts.cwd) ?? detected;
5813
6157
  const adapter = resolve(opts.target, resolutionPreset, opts.runner);
5814
6158
  const preset = opts.preset ?? adapter.declaredPreset?.(opts.cwd) ?? detected;
5815
- const contradiction = presetContradiction(preset, opts.cwd);
5816
- if (contradiction !== void 0) {
5817
- process.stderr.write(`sentinel (${opts.target}): ${contradiction}
6159
+ if (opts.preset !== void 0) {
6160
+ const contradiction = presetContradiction(opts.preset, opts.cwd);
6161
+ if (contradiction !== void 0) {
6162
+ process.stderr.write(`sentinel (${opts.target}): ${contradiction}
5818
6163
  `);
5819
- return 1;
6164
+ return 1;
6165
+ }
6166
+ }
6167
+ let effective = preset;
6168
+ if (opts.preset === void 0 && adapter.declaredPreset?.(opts.cwd) === void 0) {
6169
+ const [signal] = presetSignals(opts.cwd);
6170
+ if (signal !== void 0 && signal.preset !== preset) {
6171
+ effective = signal.preset;
6172
+ process.stderr.write(
6173
+ ` detected "${preset}" from dependencies, but ${signal.evidence}, so adopting as "${signal.preset}". Pass --preset to override.
6174
+ `
6175
+ );
6176
+ }
5820
6177
  }
5821
- const context = { cwd: opts.cwd, preset };
6178
+ const context = { cwd: opts.cwd, preset: effective };
5822
6179
  const plan3 = await adapter.plan(context);
5823
6180
  if (plan3.blocked) {
5824
6181
  process.stderr.write(`sentinel (${opts.target}): ${plan3.blocked}
@@ -5843,7 +6200,7 @@ async function dispatch(opts) {
5843
6200
  process.stderr.write(` fixing what ${opts.target} can fix automatically...
5844
6201
  `);
5845
6202
  await adapter.afterInit({
5846
- module: basename2(opts.cwd),
6203
+ module: basename3(opts.cwd),
5847
6204
  cwd: opts.cwd,
5848
6205
  preset,
5849
6206
  ci: false,
@@ -5866,18 +6223,19 @@ export {
5866
6223
  readOwnVersion,
5867
6224
  readProjectPackageJson,
5868
6225
  readNxProjectName,
6226
+ VERBS,
6227
+ TARGETS,
6228
+ LONG_RUNNING_TARGETS,
6229
+ SWEEPABLE_TARGETS,
6230
+ PRESET_NAMES,
5869
6231
  WORKSPACE_ROOT_MARKER,
5870
6232
  findWorkspaceRoot,
5871
6233
  ensureWorkspacePrep,
5872
6234
  inspectWorkspacePrep,
5873
6235
  palette,
5874
- VERBS,
5875
- TARGETS,
5876
- SWEEPABLE_TARGETS,
5877
- PRESET_NAMES,
5878
6236
  registerAdapters,
5879
6237
  describeFramework,
5880
6238
  detectFramework,
5881
6239
  dispatch
5882
6240
  };
5883
- //# sourceMappingURL=chunk-TWL6T237.js.map
6241
+ //# sourceMappingURL=chunk-SA2RMTVI.js.map