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

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 {
@@ -87,7 +82,7 @@ var BaseAdapter = class {
87
82
 
88
83
  // src/shared/package-json.ts
89
84
  import { existsSync, readFileSync } from "fs";
90
- import { dirname, join } from "path";
85
+ import { basename, dirname, join } from "path";
91
86
  import { fileURLToPath } from "url";
92
87
  function readOwnPackage() {
93
88
  let dir = dirname(fileURLToPath(import.meta.url));
@@ -132,19 +127,9 @@ function readNxProjectName(dir) {
132
127
  return void 0;
133
128
  }
134
129
  }
135
-
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
- };
130
+ function moduleName(dir) {
131
+ const declared = readProjectPackageJson(dir).name;
132
+ return readNxProjectName(dir) ?? (declared === void 0 ? basename(dir) : declared);
148
133
  }
149
134
 
150
135
  // src/core/domain.ts
@@ -166,7 +151,21 @@ var SWEEPABLE_TARGETS = TARGETS.filter(
166
151
  );
167
152
  var PRESET_NAMES = ["react", "nest", "svelte", "node", "tools"];
168
153
 
169
- // src/roles/build/adapters/vite/vite-dev.adapter.ts
154
+ // src/shared/color.ts
155
+ import { styleText } from "util";
156
+ function palette(stream) {
157
+ const paint = (format, text) => styleText(format, text, { stream });
158
+ return {
159
+ ok: (text) => paint(["green", "bold"], text),
160
+ fail: (text) => paint(["red", "bold"], text),
161
+ warn: (text) => paint("yellow", text),
162
+ strong: (text) => paint("bold", text),
163
+ dim: (text) => paint("dim", text),
164
+ accent: (text) => paint("cyan", text)
165
+ };
166
+ }
167
+
168
+ // src/roles/build/adapters/vite/vite-role.adapter.ts
170
169
  import { spawnSync } from "child_process";
171
170
 
172
171
  // src/core/config/tool-args.ts
@@ -206,9 +205,9 @@ function binFromOwnInstall(packageName, binName) {
206
205
  try {
207
206
  const manifest = require2.resolve(`${packageName}/package.json`);
208
207
  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);
208
+ const relative6 = typeof bin === "string" ? bin : bin?.[binName];
209
+ if (!relative6) return void 0;
210
+ const executable = join2(dirname2(manifest), relative6);
212
211
  return existsSync3(executable) ? executable : void 0;
213
212
  } catch {
214
213
  return void 0;
@@ -221,8 +220,8 @@ function binSearchPath(cwd) {
221
220
  }
222
221
 
223
222
  // src/roles/build/plan.ts
224
- import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
225
- import { join as join7 } from "path";
223
+ import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
224
+ import { join as join8 } from "path";
226
225
 
227
226
  // src/core/config/existing-command.ts
228
227
  import { readFileSync as readFileSync3 } from "fs";
@@ -242,7 +241,7 @@ function parseJsonc(text, source = "config") {
242
241
 
243
242
  // src/core/config/manifest.ts
244
243
  import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
245
- import { basename, join as join3 } from "path";
244
+ import { basename as basename2, join as join3 } from "path";
246
245
  function moduleScripts(cwd) {
247
246
  try {
248
247
  const pkg = JSON.parse(readFileSync2(join3(cwd, "package.json"), "utf8"));
@@ -251,7 +250,7 @@ function moduleScripts(cwd) {
251
250
  return {};
252
251
  }
253
252
  }
254
- function moduleName(cwd) {
253
+ function moduleName2(cwd) {
255
254
  try {
256
255
  const pkg = JSON.parse(readFileSync2(join3(cwd, "package.json"), "utf8"));
257
256
  return pkg.name;
@@ -260,7 +259,7 @@ function moduleName(cwd) {
260
259
  }
261
260
  }
262
261
  function isSelfAdoption(cwd) {
263
- return moduleName(cwd) === readOwnPackage().name;
262
+ return moduleName2(cwd) === readOwnPackage().name;
264
263
  }
265
264
  function selfCommand(cwd, flags) {
266
265
  const own = readOwnPackage();
@@ -279,7 +278,7 @@ function manifestOperation(cwd, scripts) {
279
278
  return {
280
279
  kind: "merge-json",
281
280
  path: "package.json",
282
- value: { name: readNxProjectName(cwd) ?? basename(cwd), private: true, ...value }
281
+ value: { name: readNxProjectName(cwd) ?? basename2(cwd), private: true, ...value }
283
282
  };
284
283
  }
285
284
 
@@ -383,33 +382,111 @@ function keepsOtherCommands(script, sentinelCommand) {
383
382
  return script.split(SEGMENT_SEPARATOR).filter((_, index) => !isSeparatorAt(index)).some((segment) => segment.trim() !== "" && segment.trim() !== sentinelCommand);
384
383
  }
385
384
 
385
+ // src/roles/build/presets/requirements.json
386
+ var requirements_default = {
387
+ svelte: {
388
+ why: "sentinel ships Vite 8, and @sveltejs/vite-plugin-svelte caps at Vite 6 until its major 7",
389
+ requires: {
390
+ "@sveltejs/vite-plugin-svelte": "7.0.0",
391
+ svelte: "5.46.4"
392
+ }
393
+ }
394
+ };
395
+
396
+ // src/roles/build/presets/toolchain.json
397
+ var toolchain_default = {
398
+ $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.",
399
+ owned: [
400
+ {
401
+ package: "vite",
402
+ specifier: "vite",
403
+ named: ["defineConfig", "loadEnv", "mergeConfig"],
404
+ types: [
405
+ "Plugin",
406
+ "PluginOption",
407
+ "SassPreprocessorOptions",
408
+ "UserConfig",
409
+ "UserConfigExport"
410
+ ],
411
+ 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."
412
+ },
413
+ {
414
+ package: "@vitejs/plugin-react",
415
+ specifier: "@vitejs/plugin-react",
416
+ default: "react",
417
+ why: "the React transform. Bound to the Vite that runs it, so it has to come from the same install."
418
+ },
419
+ {
420
+ package: "@tailwindcss/vite",
421
+ specifier: "@tailwindcss/vite",
422
+ default: "tailwindcss",
423
+ why: "the Tailwind pipeline, identical in all three front apps."
424
+ },
425
+ {
426
+ package: "vite-plugin-svgr",
427
+ specifier: "vite-plugin-svgr",
428
+ default: "svgr",
429
+ why: "SVG as components, identical in all three front apps."
430
+ },
431
+ {
432
+ package: "nitro",
433
+ specifier: "nitro/vite",
434
+ named: ["nitro"],
435
+ 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."
436
+ }
437
+ ],
438
+ add: [
439
+ {
440
+ package: "@hublo/sentinel",
441
+ 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."
442
+ }
443
+ ],
444
+ $notOwned: {
445
+ "@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.",
446
+ "@locator/babel-jsx": "same, and same owners.",
447
+ "@tanstack/react-start": "a framework the apps code against: 48 source files import it directly.",
448
+ "@tanstack/router-plugin": "generates the app's own route tree.",
449
+ "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."
450
+ }
451
+ };
452
+
453
+ // src/roles/build/preset-data.ts
454
+ var REQUIREMENTS = requirements_default;
455
+ var OWNED_PACKAGES = toolchain_default.owned;
456
+
386
457
  // src/roles/build/config-policy.ts
458
+ var COMPANION_CONFIG_FILES = [
459
+ "vitest.config.ts",
460
+ "vitest.config.mts",
461
+ "vitest.config.js",
462
+ "vitest.config.mjs"
463
+ ];
387
464
  var BUILD_CONFIG_FILES = [
388
465
  "vite.config.ts",
389
466
  "vite.config.mts",
390
467
  "vite.config.js",
391
468
  "vite.config.mjs"
392
469
  ];
393
- var BUILD_PRESET_SPECIFIER = "@hublo/sentinel/build/react";
470
+ var BUILD_PRESET_SPECIFIERS = {
471
+ react: "@hublo/sentinel/build/react",
472
+ nest: "@hublo/sentinel/build/nest"
473
+ };
474
+ var BUILD_PRESET_SPECIFIER = BUILD_PRESET_SPECIFIERS.react;
394
475
  var BUILD_SCRIPT_NAME = "build";
395
- var SENTINEL_BUILD_COMMAND = "sentinel --run --build";
476
+ var SENTINEL_BUILD_COMMAND = "sentinel --run --build --";
396
477
  var DEV_SCRIPT_NAME = "serve";
397
478
  var SENTINEL_DEV_COMMAND = "sentinel --run --dev";
398
479
  function buildTarget() {
399
480
  return {
400
481
  [BUILD_SCRIPT_NAME]: {
401
482
  cache: true,
402
- inputs: ["default", "^default", `{projectRoot}/${BUILD_CONFIG_FILES[0]}`]
483
+ inputs: ["default", "^default", ...BUILD_CONFIG_FILES.map((name) => `{projectRoot}/${name}`)]
403
484
  }
404
485
  };
405
486
  }
406
- var SENTINEL_OWNED_BUILD_PACKAGES = [
407
- "vite",
408
- "@vitejs/plugin-react",
409
- "@tailwindcss/vite",
410
- "vite-plugin-svgr",
411
- "nitro"
412
- ];
487
+ var SENTINEL_OWNED_BUILD_PACKAGES = OWNED_PACKAGES.map(
488
+ (entry) => entry.package
489
+ );
413
490
 
414
491
  // src/roles/build/dev-script.ts
415
492
  var CHAIN = " -- ";
@@ -431,9 +508,129 @@ function composeDevScript(existing, command) {
431
508
  return chunks.join(CHAIN);
432
509
  }
433
510
 
434
- // src/roles/build/read-adoption.ts
511
+ // src/roles/build/nest/adopt.ts
512
+ import { relative as relative2 } from "path";
513
+
514
+ // src/roles/build/nest/nx-target.ts
435
515
  import { existsSync as existsSync6, readFileSync as readFileSync4 } from "fs";
436
- import { join as join6 } from "path";
516
+ import { join as join6, relative } from "path";
517
+ var DEPRECATED_NX_BUILDER = "@nx/webpack:webpack";
518
+ var TRANSLATABLE_WEBPACK_CONFIG = "tools/webpack-configs/update.webpack.config.js";
519
+ var readProjectJson = (cwd) => {
520
+ const path = join6(cwd, "project.json");
521
+ if (!existsSync6(path)) return void 0;
522
+ try {
523
+ return JSON.parse(readFileSync4(path, "utf8"));
524
+ } catch {
525
+ return void 0;
526
+ }
527
+ };
528
+ function webpackBuildTarget(cwd) {
529
+ const project = readProjectJson(cwd);
530
+ const target = project?.targets?.build;
531
+ if (target?.executor !== DEPRECATED_NX_BUILDER) return void 0;
532
+ const { outputPath, main, webpackConfig } = target.options ?? {};
533
+ if (typeof project?.name !== "string" || typeof outputPath !== "string" || typeof main !== "string" || typeof webpackConfig !== "string") {
534
+ return void 0;
535
+ }
536
+ return {
537
+ project: project.name,
538
+ outputPath,
539
+ main,
540
+ webpackConfig,
541
+ configurations: target.configurations
542
+ };
543
+ }
544
+ function refusalFor(cwd, target) {
545
+ if (workspaceRootFor(cwd, target) === void 0) {
546
+ return `this service's build target declares \`main: ${target.main}\`, which does not place this directory inside a workspace. Every value the config needs is derived from that path, so a wrong one would resolve \`workspaceRoot\` to the service itself and leave the alias table resolving nothing. Nothing was written.`;
547
+ }
548
+ if (target.webpackConfig !== TRANSLATABLE_WEBPACK_CONFIG) {
549
+ return `this service builds with ${target.webpackConfig}, not the shared ${TRANSLATABLE_WEBPACK_CONFIG} this preset reproduces. Its own config adds loaders or rules the preset has no equivalent for, and translating it anyway would produce a build that succeeds and a service that fails later. Read that file, decide what the Vite config needs, and write it by hand. Nothing was written.`;
550
+ }
551
+ const live = liveFileReplacements(cwd, target);
552
+ if (live.length > 0) {
553
+ return `this service's production configuration replaces ${live.join(", ")}, and those files exist. The preset has no equivalent, so translating the target would drop a real substitution. Nothing was written.`;
554
+ }
555
+ return void 0;
556
+ }
557
+ function liveFileReplacements(cwd, target) {
558
+ const workspaceRoot = workspaceRootFor(cwd, target);
559
+ if (workspaceRoot === void 0) return [];
560
+ const configurations = target.configurations ?? {};
561
+ const live = [];
562
+ for (const configuration of Object.values(configurations)) {
563
+ for (const replacement of configuration.fileReplacements ?? []) {
564
+ const exists = existsSync6(join6(workspaceRoot, replacement.replace)) || existsSync6(join6(workspaceRoot, replacement.with));
565
+ if (exists) live.push(replacement.replace);
566
+ }
567
+ }
568
+ return live;
569
+ }
570
+ function workspaceRootFor(cwd, target) {
571
+ const moduleRoot = target.main.replace(/\/src\/.*$/, "");
572
+ if (moduleRoot === target.main || !cwd.endsWith(moduleRoot)) return void 0;
573
+ return cwd.slice(0, cwd.length - moduleRoot.length - 1);
574
+ }
575
+ function workspaceRootHops(cwd, target) {
576
+ const root = workspaceRootFor(cwd, target);
577
+ if (root === void 0) {
578
+ throw new Error(`sentinel build(nest): cannot place ${cwd} in a workspace from ${target.main}`);
579
+ }
580
+ const rel = relative(root, cwd);
581
+ return rel === "" ? 0 : rel.split(/[\\/]/).length;
582
+ }
583
+
584
+ // src/roles/build/nest/adopt.ts
585
+ var NEST_CONFIG_FILE = "vite.config.mts";
586
+ function nestConfigSource(cwd, target) {
587
+ const hops = workspaceRootHops(cwd, target);
588
+ const up = Array.from({ length: hops }, () => "..").join("/");
589
+ return `import path from 'node:path'
590
+
591
+ import { nestService } from '${BUILD_PRESET_SPECIFIERS.nest}'
592
+
593
+ export default nestService({
594
+ project: '${target.project}',
595
+ root: __dirname,
596
+ workspaceRoot: path.resolve(__dirname, '${up}'),
597
+ outDir: '${target.outputPath}',
598
+ })
599
+ `;
600
+ }
601
+ function nestBuildTarget(cwd, target) {
602
+ const workspaceRoot = workspaceRootFor(cwd, target);
603
+ if (workspaceRoot === void 0) {
604
+ throw new Error(
605
+ `sentinel build(nest): cannot place ${cwd} inside a workspace from ${target.main}`
606
+ );
607
+ }
608
+ const moduleRoot = relative2(workspaceRoot, cwd);
609
+ return {
610
+ executor: "nx:run-commands",
611
+ outputs: [`{workspaceRoot}/${target.outputPath}`],
612
+ options: {
613
+ cwd: moduleRoot,
614
+ command: "pnpm exec sentinel --run --build",
615
+ forwardAllArgs: false
616
+ }
617
+ };
618
+ }
619
+ function nestAdoptionOperations(cwd, target) {
620
+ return [
621
+ { kind: "write", path: NEST_CONFIG_FILE, contents: nestConfigSource(cwd, target) },
622
+ { kind: "remove-json-keys", path: "project.json", keys: [["targets", "build"]] },
623
+ {
624
+ kind: "merge-json",
625
+ path: "project.json",
626
+ value: { targets: { build: nestBuildTarget(cwd, target) } }
627
+ }
628
+ ];
629
+ }
630
+
631
+ // src/roles/build/read-adoption.ts
632
+ import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
633
+ import { join as join7 } from "path";
437
634
  var NOT_ADOPTED = (configFile, unreadable = null) => ({
438
635
  configFile,
439
636
  preset: null,
@@ -444,19 +641,32 @@ var NOT_ADOPTED = (configFile, unreadable = null) => ({
444
641
  unreadable
445
642
  });
446
643
  function buildConfigFile(cwd) {
447
- return BUILD_CONFIG_FILES.find((name) => existsSync6(join6(cwd, name)));
644
+ return BUILD_CONFIG_FILES.find((name) => existsSync7(join7(cwd, name)));
645
+ }
646
+ function importsSpecifier(source, specifier) {
647
+ const escaped = specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
648
+ return new RegExp(`(?:from|import)\\s*\\(?\\s*['"\`]${escaped}(?:/[^'"\`]*)?['"\`]`).test(source);
448
649
  }
449
650
  function importsPreset(source) {
450
- const specifier = BUILD_PRESET_SPECIFIER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
451
- return new RegExp(`(?:from|import)\\s*\\(?\\s*['"\`]${specifier}['"\`]`).test(source);
651
+ return Object.values(BUILD_PRESET_SPECIFIERS).some(
652
+ (specifier) => importsSpecifier(source, specifier)
653
+ );
452
654
  }
453
655
  var REACT_PLUGIN_SPECIFIERS = ["@vitejs/plugin-react", "@tanstack/react-start"];
454
656
  function declaredBuildPreset(cwd) {
657
+ const source = readBuildConfigSource(cwd);
658
+ if (source === void 0) {
659
+ return webpackBuildTarget(cwd) === void 0 ? void 0 : "nest";
660
+ }
661
+ if (importsSpecifier(source, BUILD_PRESET_SPECIFIERS.nest)) return "nest";
662
+ if (importsPreset(source)) return "react";
663
+ return REACT_PLUGIN_SPECIFIERS.some((specifier) => importsSpecifier(source, specifier)) ? "react" : void 0;
664
+ }
665
+ function readBuildConfigSource(cwd) {
455
666
  const configFile = buildConfigFile(cwd);
456
667
  if (!configFile) return void 0;
457
668
  try {
458
- const source = readFileSync4(join6(cwd, configFile), "utf8");
459
- return REACT_PLUGIN_SPECIFIERS.some((specifier) => source.includes(specifier)) ? "react" : void 0;
669
+ return readFileSync5(join7(cwd, configFile), "utf8");
460
670
  } catch {
461
671
  return void 0;
462
672
  }
@@ -466,7 +676,7 @@ function readBuildAdoption(cwd) {
466
676
  if (!configFile) return NOT_ADOPTED(null);
467
677
  let source;
468
678
  try {
469
- source = readFileSync4(join6(cwd, configFile), "utf8");
679
+ source = readFileSync5(join7(cwd, configFile), "utf8");
470
680
  } catch (error) {
471
681
  return NOT_ADOPTED(configFile, error instanceof Error ? error.message : String(error));
472
682
  }
@@ -478,7 +688,9 @@ function readBuildAdoption(cwd) {
478
688
  );
479
689
  return {
480
690
  configFile,
481
- preset: "react",
691
+ // Read from the config rather than assumed: the two presets are adopted the same way and
692
+ // reporting the wrong one would make `--inspect` lie about what a module builds.
693
+ preset: declaredBuildPreset(cwd) ?? "react",
482
694
  adopted: true,
483
695
  conformant: ownDeclarations.length === 0,
484
696
  drift: ownDeclarations.map(
@@ -489,39 +701,131 @@ function readBuildAdoption(cwd) {
489
701
  };
490
702
  }
491
703
 
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'
704
+ // src/roles/build/requirements.ts
705
+ function atLeast(declared, needed) {
706
+ const parse2 = (value) => value.replace(/^[\^~>=<\s]+/, "").split("-")[0].split(".").map((part) => Number.parseInt(part, 10) || 0);
707
+ const left = parse2(declared);
708
+ const right = parse2(needed);
709
+ for (let at = 0; at < Math.max(left.length, right.length); at++) {
710
+ const a = left[at] ?? 0;
711
+ const b = right[at] ?? 0;
712
+ if (a !== b) return a > b;
713
+ }
714
+ return true;
715
+ }
716
+ function unmetRequirements(preset, declared) {
717
+ const entry = REQUIREMENTS[preset];
718
+ if (!entry) return [];
719
+ return Object.entries(entry.requires).filter(([name, needed]) => {
720
+ const have = declared[name];
721
+ return have === void 0 || !atLeast(have, needed);
722
+ }).map(([name, needed]) => ({ name, declared: declared[name], needed }));
723
+ }
724
+ function describeUnmet(preset, unmet) {
725
+ const entry = REQUIREMENTS[preset];
726
+ const list = unmet.map(
727
+ ({ name, declared, needed }) => `${name} ${declared === void 0 ? "is not declared here" : `is at ${declared}`}, needs >= ${needed}`
728
+ ).join("; ");
729
+ 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\`.`;
730
+ }
498
731
 
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
- `;
732
+ // src/roles/build/rewrite-imports.ts
733
+ import { parseSync } from "oxc-parser";
734
+ var MOVED_SPECIFIERS = new Set(OWNED_PACKAGES.map((entry) => entry.specifier));
735
+ function esmBlocker(fileName, packageType) {
736
+ if (/\.(mts|mjs)$/.test(fileName)) return void 0;
737
+ if (packageType === "module") return void 0;
738
+ 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.`;
739
+ }
740
+ var DEFAULT_EXPORT_NAMES = Object.fromEntries(
741
+ OWNED_PACKAGES.filter((entry) => entry.default !== void 0).map((entry) => [
742
+ entry.specifier,
743
+ entry.default
744
+ ])
745
+ );
746
+ function renderImport(bindings) {
747
+ const render = (list) => list.map((b) => b.exported === b.local ? b.exported : `${b.exported} as ${b.local}`).sort((left, right) => left.localeCompare(right)).join(", ");
748
+ const values = bindings.filter((b) => !b.typeOnly);
749
+ const types = bindings.filter((b) => b.typeOnly);
750
+ const lines = [];
751
+ if (values.length > 0) lines.push(`import { ${render(values)} } from '${BUILD_PRESET_SPECIFIER}'`);
752
+ if (types.length > 0) {
753
+ lines.push(`import type { ${render(types)} } from '${BUILD_PRESET_SPECIFIER}'`);
754
+ }
755
+ return lines.join("\n");
756
+ }
757
+ function rewriteBuildImports(source, fileName = "vite.config.ts") {
758
+ const { program, errors } = parseSync(fileName, source, { sourceType: "module" });
759
+ if (errors.length > 0) {
760
+ return {
761
+ kind: "blocked",
762
+ why: `${fileName} does not parse: ${errors[0]?.message ?? "unknown"}`
763
+ };
764
+ }
765
+ const imports = program.body.filter(
766
+ (node) => node.type === "ImportDeclaration"
767
+ );
768
+ if (imports.some((node) => node.source.value === BUILD_PRESET_SPECIFIER)) {
769
+ return { kind: "unchanged", why: "this config already imports its toolchain from sentinel" };
770
+ }
771
+ const moving = imports.filter((node) => MOVED_SPECIFIERS.has(node.source.value));
772
+ if (moving.length === 0) {
773
+ return {
774
+ kind: "unchanged",
775
+ why: "this config imports none of the packages sentinel owns, so there is nothing to move"
776
+ };
777
+ }
778
+ const bindings = [];
779
+ for (const node of moving) {
780
+ const from = node.source.value;
781
+ const specifiers = node.specifiers ?? [];
782
+ if (specifiers.length === 0) {
783
+ return { kind: "blocked", why: `\`import '${from}'\` has no bindings to move` };
784
+ }
785
+ for (const spec of specifiers) {
786
+ const typeOnly = node.importKind === "type" || spec.importKind === "type";
787
+ if (spec.type === "ImportDefaultSpecifier") {
788
+ const exported = DEFAULT_EXPORT_NAMES[from];
789
+ if (!exported) {
790
+ return {
791
+ kind: "blocked",
792
+ why: `no sentinel export for the default import of \`${from}\``
793
+ };
794
+ }
795
+ bindings.push({ exported, local: spec.local.name, typeOnly });
796
+ continue;
797
+ }
798
+ if (spec.type === "ImportSpecifier") {
799
+ bindings.push({
800
+ exported: spec.imported?.name ?? spec.imported?.value ?? spec.local.name,
801
+ local: spec.local.name,
802
+ typeOnly
803
+ });
804
+ continue;
805
+ }
806
+ return {
807
+ kind: "blocked",
808
+ why: `\`import * as ${spec.local.name} from '${from}'\` cannot be mapped onto sentinel's named exports`
809
+ };
810
+ }
811
+ }
812
+ const spans = moving.map((node) => ({ start: node.start, end: node.end })).sort((left, right) => left.start - right.start);
813
+ const first = spans[0];
814
+ const rest = spans.slice(1);
815
+ let out = source;
816
+ for (const span of rest.slice().reverse()) {
817
+ const after = out.slice(span.end).match(/^\r?\n/)?.[0].length ?? 0;
818
+ out = out.slice(0, span.start) + out.slice(span.end + after);
819
+ }
820
+ out = out.slice(0, first.start) + renderImport(bindings) + out.slice(first.end);
821
+ return {
822
+ kind: "rewritten",
823
+ source: out,
824
+ moved: [...new Set(moving.map((node) => node.source.value))].sort()
825
+ };
524
826
  }
827
+
828
+ // src/roles/build/plan.ts
525
829
  function ownedBuildDependencies(cwd) {
526
830
  const manifest = readProjectPackageJson(cwd);
527
831
  const keys = [];
@@ -550,32 +854,93 @@ function buildScripts(cwd) {
550
854
  };
551
855
  const existingDev = existingCommand(cwd, DEV_SCRIPT_NAME);
552
856
  if (existingDev !== void 0) {
857
+ const own2 = moduleName(cwd);
553
858
  scripts[DEV_SCRIPT_NAME] = composeDevScript(
554
859
  existingDev,
555
- selfCommand(cwd, "--run --dev") ?? SENTINEL_DEV_COMMAND
860
+ // Trailing `--` for the same reason as the build script: whatever the caller appends
861
+ // reaches Vite, not sentinel's parser. It goes after `--module`, which is sentinel's own.
862
+ `${selfCommand(cwd, "--run --dev") ?? SENTINEL_DEV_COMMAND} --module ${own2} --`
556
863
  );
557
864
  }
558
865
  return scripts;
559
866
  }
560
867
  function plan(context) {
868
+ const manifest = readProjectPackageJson(context.cwd);
869
+ const unmet = unmetRequirements(context.preset, {
870
+ ...manifest.dependencies,
871
+ ...manifest.devDependencies
872
+ });
873
+ if (unmet.length > 0) {
874
+ return { operations: [], skipped: describeUnmet(context.preset, unmet) };
875
+ }
561
876
  if (declaredBuildPreset(context.cwd) === void 0 && buildConfigFile(context.cwd) !== void 0) {
562
877
  return {
563
878
  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.`
879
+ 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
880
  };
566
881
  }
567
882
  const notes = [];
568
883
  const operations = [];
569
884
  const configFile = buildConfigFile(context.cwd);
885
+ const webpackTarget = webpackBuildTarget(context.cwd);
886
+ const isNest = declaredBuildPreset(context.cwd) === "nest" || webpackTarget !== void 0;
887
+ if (isNest) {
888
+ if (webpackTarget === void 0) {
889
+ return {
890
+ operations: [],
891
+ skipped: `already building through sentinel: ${configFile ?? NEST_CONFIG_FILE} imports the nest preset and the nx target runs it. Nothing to change.`
892
+ };
893
+ }
894
+ const refusal = refusalFor(context.cwd, webpackTarget);
895
+ if (refusal !== void 0) return { operations: [], blocked: refusal };
896
+ const dropped = Object.keys(webpackTarget.configurations ?? {});
897
+ return {
898
+ operations: nestAdoptionOperations(context.cwd, webpackTarget),
899
+ notes: [
900
+ `wrote ${NEST_CONFIG_FILE} and pointed the nx build target at sentinel. The target stays \`nx:run-commands\` with \`forwardAllArgs: false\`, so the image's \`--generatePackageJson\` still works and the Dockerfile needs no change.`,
901
+ ...dropped.length > 0 ? [
902
+ `dropped the \`${dropped.join("`, `")}\` configuration${dropped.length > 1 ? "s" : ""} from the build target: its fileReplacements point at files that do not exist, so it replaced nothing. A live one would have blocked this instead.`
903
+ ] : [],
904
+ `verify with \`nx run ${webpackTarget.project}:generate-swagger-file\` and \`git diff --exit-code\`: it runs the built bundle, so it proves the service boots AND that its published contract did not move.`
905
+ ]
906
+ };
907
+ }
570
908
  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
- );
909
+ return {
910
+ operations: [],
911
+ 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 (36 services here build through \`@nx/webpack\`), the build role does not apply to it.`
912
+ };
913
+ } else {
914
+ const notEsm = esmBlocker(configFile, readProjectPackageJson(context.cwd).type);
915
+ if (notEsm !== void 0) return { operations: [], skipped: notEsm };
916
+ const rewrite = rewriteBuildImports(readConfigSource(context.cwd, configFile), configFile);
917
+ if (rewrite.kind === "blocked") {
918
+ return {
919
+ operations: [],
920
+ 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.`
921
+ };
922
+ }
923
+ if (rewrite.kind === "rewritten") {
924
+ operations.push({ kind: "write", path: configFile, contents: rewrite.source });
925
+ }
926
+ for (const name of COMPANION_CONFIG_FILES) {
927
+ const source = readConfigSource(context.cwd, name);
928
+ if (source === "") continue;
929
+ const companion = rewriteBuildImports(source, name);
930
+ if (companion.kind === "rewritten") {
931
+ operations.push({ kind: "write", path: name, contents: companion.source });
932
+ notes.push(`${name} now imports ${companion.moved.join(", ")} from sentinel too.`);
933
+ } else if (companion.kind === "blocked") {
934
+ notes.push(
935
+ `${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.`
936
+ );
937
+ }
938
+ }
939
+ if (rewrite.kind === "rewritten") {
940
+ notes.push(
941
+ `${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.`
942
+ );
943
+ }
579
944
  }
580
945
  operations.push(manifestOperation(context.cwd, buildScripts(context.cwd)));
581
946
  operations.push(...nxTargetOperations({ cwd: context.cwd, targets: buildTarget() }));
@@ -588,134 +953,186 @@ function plan(context) {
588
953
  }
589
954
  return { operations, notes };
590
955
  }
591
- function readsPreset(cwd, configFile) {
592
- if (!existsSync7(join7(cwd, configFile))) return false;
956
+ function readConfigSource(cwd, configFile) {
957
+ if (!existsSync8(join8(cwd, configFile))) return "";
593
958
  try {
594
- return readFileSync5(join7(cwd, configFile), "utf8").includes(BUILD_PRESET_SPECIFIER);
959
+ return readFileSync6(join8(cwd, configFile), "utf8");
595
960
  } catch {
596
- return false;
961
+ return "";
597
962
  }
598
963
  }
599
964
 
600
965
  // src/roles/build/resolve-vite.ts
601
- function resolveVite(cwd) {
602
- return binFromOwnInstall("vite", "vite") ?? resolveBin(cwd, "vite");
966
+ function resolveViteWith(cwd) {
967
+ const own = binFromOwnInstall("vite", "vite");
968
+ if (own) return { bin: own, origin: "sentinel" };
969
+ const module_ = resolveBin(cwd, "vite");
970
+ return module_ ? { bin: module_, origin: "module" } : { bin: void 0, origin: "none" };
603
971
  }
604
972
  function viteOrigin(cwd) {
605
- if (binFromOwnInstall("vite", "vite")) return "sentinel";
606
- return resolveBin(cwd, "vite") ? "module" : "none";
973
+ return resolveViteWith(cwd).origin;
607
974
  }
608
975
 
609
- // src/roles/build/adapters/vite/vite-dev.adapter.ts
610
- var ViteDevAdapter = class extends BaseAdapter {
611
- target = "dev";
976
+ // src/roles/build/adapters/vite/vite-role.adapter.ts
977
+ var VITE_VALUE_FLAGS = [
978
+ "--config",
979
+ "-c",
980
+ "--mode",
981
+ "-m",
982
+ "--outDir",
983
+ "--logLevel",
984
+ "--base",
985
+ "--host",
986
+ "--port"
987
+ ];
988
+ var ViteRoleAdapter = class extends BaseAdapter {
612
989
  runner = "vite";
990
+ /**
991
+ * The presets this role has something to SAY about, which is wider than the ones it serves.
992
+ *
993
+ * React it serves. Svelte it does not, yet, and the difference matters to a developer: the
994
+ * plugin caps at Vite 6 until its major 7, which needs `svelte ^5.46.4`, so the role applies
995
+ * once the module moves. `plan` reports that chain and SKIPS, which it can only do if
996
+ * resolution let it run at all — filtering here would give "no adapter for preset svelte",
997
+ * which tells nobody what to do about it.
998
+ *
999
+ * Nest it now serves too, through `@hublo/sentinel/build/nest`. It used to be declined on the
1000
+ * grounds that "there is nothing to bundle", which was never true: those 36 services bundle
1001
+ * with webpack. What changed is that Nx v24 removes the `@nx/webpack:webpack` executor and
1002
+ * the `composePlugins` / `withNx` helpers their shared config is built on, and nx's own
1003
+ * migration generator refuses every one of them because they use `@nx/js:node`
1004
+ * (nrwl/nx#36389). So the way out runs through here.
1005
+ *
1006
+ * `node` and `tools` remain genuinely declined: nothing under them bundles at all.
1007
+ */
613
1008
  appliesTo(preset) {
614
- return preset === "react";
1009
+ return preset === "react" || preset === "svelte" || preset === "nest";
615
1010
  }
1011
+ /**
1012
+ * `react`, read from the module's Vite config rather than from its dependencies.
1013
+ *
1014
+ * Without this the role is unreachable in practice: the toolchain is declared at the ROOT,
1015
+ * so every front app detects as `node` and `appliesTo` filters the adapter out — including
1016
+ * during `sentinel --inspect` with no target, which never passes `--preset`.
1017
+ */
616
1018
  declaredPreset(cwd) {
617
1019
  return declaredBuildPreset(cwd);
618
1020
  }
619
1021
  /**
620
- * The same plan as `--build`, deliberately.
1022
+ * The same plan for both targets, deliberately.
621
1023
  *
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.
1024
+ * `build` and `serve` are one app's two calls into one toolchain, so adopting one without the
1025
+ * other leaves the module half-migrated in a way nothing reports. The operations are
1026
+ * idempotent, so `--init --build` and `--init --dev` are interchangeable rather than additive.
626
1027
  */
627
1028
  plan(context) {
628
1029
  return plan(context);
629
1030
  }
1031
+ async status(ctx) {
1032
+ const { adopted, preset, conformant, drift, unreadable } = readBuildAdoption(ctx.cwd);
1033
+ return { adopted, preset, conformant, drift, unreadable };
1034
+ }
630
1035
  /**
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.
1036
+ * Adoption, the binary, and the warning about which copy of Vite is running.
633
1037
  *
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.
1038
+ * A module that has not adopted is reported and PASSES, the same stance every other role
1039
+ * takes: `--run` with no named target sweeps every wired target across the workspace, so
1040
+ * failing here would exit non-zero on every module that has not migrated, which is most of
1041
+ * them.
637
1042
  */
638
- async run(ctx) {
1043
+ prepare(ctx) {
639
1044
  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
- `
1045
+ this.say(
1046
+ `no sentinel preset in this module's Vite config; run \`sentinel --init --build\` to adopt.`
643
1047
  );
644
- return { ok: true, code: 0 };
1048
+ return { kind: "stop", result: { ok: true, code: 0 } };
645
1049
  }
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
- `
1050
+ const { bin, origin } = resolveViteWith(ctx.cwd);
1051
+ if (!bin) {
1052
+ this.say(
1053
+ `could not find the vite binary (looked in ${binSearchPath(ctx.cwd)}). Run \`pnpm install\` in the module.`
651
1054
  );
652
- return { ok: false, code: 1 };
1055
+ return { kind: "stop", result: { ok: false, code: 1 } };
653
1056
  }
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
- `
1057
+ if (origin === "module") {
1058
+ this.say(
1059
+ `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
1060
  );
659
1061
  }
660
- const { options, paths } = splitToolArgs(ctx.cwd, ctx.toolArgs, { valueFlags: [] });
661
- const result = spawnSync(vite, [...options, ...paths], { cwd: ctx.cwd, stdio: "inherit" });
1062
+ return { kind: "ready", vite: bin };
1063
+ }
1064
+ /**
1065
+ * Run Vite with the caller's own arguments after `--`, and report what happened.
1066
+ *
1067
+ * `whenSignalled` is the only difference between the two targets here, and it is a real one:
1068
+ * a build killed by a signal did not produce a bundle, while a dev server killed by Ctrl-C
1069
+ * did exactly what the developer asked.
1070
+ */
1071
+ spawnVite(ctx, vite, argv, whenSignalled) {
1072
+ const { options, paths } = splitToolArgs(ctx.cwd, ctx.toolArgs, {
1073
+ valueFlags: VITE_VALUE_FLAGS
1074
+ });
1075
+ const result = spawnSync(vite, [...argv, ...options, ...paths], {
1076
+ cwd: ctx.cwd,
1077
+ stdio: "inherit"
1078
+ });
662
1079
  if (result.error) {
663
- process.stderr.write(`sentinel dev(vite): could not run vite (${result.error.message})
664
- `);
1080
+ this.say(`could not run vite (${result.error.message})`);
665
1081
  return { ok: false, code: 1 };
666
1082
  }
667
- const code = result.status ?? 0;
1083
+ const code = result.status ?? whenSignalled;
668
1084
  return { ok: code === 0, code };
669
1085
  }
670
- async status(ctx) {
671
- const { adopted, preset, conformant, drift, unreadable } = readBuildAdoption(ctx.cwd);
672
- return { adopted, preset, conformant, drift, unreadable };
1086
+ /** One prefix for every message this role emits, so they cannot drift apart. */
1087
+ say(message) {
1088
+ process.stderr.write(`sentinel ${this.label}(vite): ${message}
1089
+ `);
673
1090
  }
674
1091
  };
675
1092
 
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
- });
1093
+ // src/roles/build/adapters/vite/vite-dev.adapter.ts
1094
+ var ViteDevAdapter = class extends ViteRoleAdapter {
1095
+ target = "dev";
1096
+ get label() {
1097
+ return "dev";
698
1098
  }
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
- });
1099
+ /**
1100
+ * `--init --dev` does the same thing as `--init --build`, and now says so.
1101
+ *
1102
+ * The shared plan is deliberate (see `ViteRoleAdapter.plan`): one plan writes both scripts,
1103
+ * because adopting one without the other leaves the module half-migrated. What was missing
1104
+ * was only the telling: the run printed a message about the build and nothing about `serve`,
1105
+ * so a developer could believe the dev server had been adopted on its own. Refusing was the
1106
+ * wrong correction, it added friction to a command that already produced the right result.
1107
+ */
1108
+ plan(context) {
1109
+ const planned = super.plan(context);
1110
+ if (planned.operations.length === 0) return planned;
1111
+ return {
1112
+ ...planned,
1113
+ notes: [
1114
+ ...planned.notes ?? [],
1115
+ "--init --dev adopts the build too: one plan writes both `build` and `serve`."
1116
+ ]
1117
+ };
706
1118
  }
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
- }
1119
+ /**
1120
+ * Start the dev server. It does not return until stopped, so there is no verdict to report
1121
+ * beyond the exit code the developer's own Ctrl-C produces.
1122
+ *
1123
+ * No `prebuild` here, unlike `--run --build`: the artefact this repo generates before a build
1124
+ * is produced by the watcher that WRAPS this command (`run-with-runtime-artifact-watch`), and
1125
+ * running it again would race the watcher that is about to own the file.
1126
+ *
1127
+ * No subcommand either. `vite`, `vite dev` and `vite serve` all start the server, and the
1128
+ * bare form is the one every version accepts.
1129
+ */
1130
+ async run(ctx) {
1131
+ const ready = this.prepare(ctx);
1132
+ if (ready.kind === "stop") return ready.result;
1133
+ return this.spawnVite(ctx, ready.vite, [], 0);
1134
+ }
1135
+ };
719
1136
 
720
1137
  // src/roles/build/prerequisite.ts
721
1138
  import { spawnSync as spawnSync2 } from "child_process";
@@ -743,122 +1160,55 @@ function runPrerequisite(cwd, env = process.env) {
743
1160
  return { kind: "ran" };
744
1161
  }
745
1162
 
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
1163
  // 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 {
1164
+ var ViteAdapter = class extends ViteRoleAdapter {
766
1165
  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);
1166
+ get label() {
1167
+ return "build";
787
1168
  }
788
1169
  /**
789
1170
  * Build the module.
790
1171
  *
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.
1172
+ * Adoption, the binary and the two-copies warning are `prepare`'s, shared with `--dev`. What
1173
+ * is this target's own is the `prebuild` step and the `build` subcommand.
795
1174
  */
796
1175
  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
- }
1176
+ const ready = this.prepare(ctx);
1177
+ if (ready.kind === "stop") return ready.result;
805
1178
  const prerequisite = runPrerequisite(ctx.cwd);
806
1179
  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
- `
1180
+ this.say(
1181
+ `the ${PREBUILD_SCRIPT_NAME} step failed, so the build was not started: ${prerequisite.reason}`
810
1182
  );
811
1183
  return { ok: false, code: prerequisite.code };
812
1184
  }
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 };
1185
+ return this.spawnVite(ctx, ready.vite, ["build"], 1);
841
1186
  }
842
1187
  /**
843
- * What this module builds with, without building it.
1188
+ * What this module builds with, WITHOUT loading its config.
844
1189
  *
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.
1190
+ * Everything reported here is read from the manifest and from the config's text: whether the
1191
+ * module imports its toolchain from sentinel, which Vite install would answer, what it still
1192
+ * declares of its own, and whether it has a `prebuild` step.
848
1193
  *
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.
1194
+ * It used to also load the config through Vite and report where it departed from a preset
1195
+ * and what it added on top. Both were dropped with the preset itself. Adoption changes a
1196
+ * config's IMPORTS and nothing else, so there is no preset the app signed up to and no
1197
+ * distance from it to measure; and computing it meant EXECUTING an app's config, which is
1198
+ * expensive and can fail on its own terms (career throws without `VITE_BRAND_ID`). Reading
1199
+ * a file to report on it must not be able to break.
852
1200
  *
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.
1201
+ * They come back with the shared base, when there is again something an app has chosen to
1202
+ * adopt and can therefore depart from.
855
1203
  */
856
1204
  async inspect(ctx) {
857
1205
  const adoption = readBuildAdoption(ctx.cwd);
858
- const base = {
1206
+ return {
859
1207
  runner: "vite",
860
1208
  configFile: adoption.configFile,
861
1209
  ...adoption.unreadable ? { unreadable: adoption.unreadable } : {},
1210
+ // The field nothing else reports: an adopted config takes its plugins from sentinel, so
1211
+ // a module answering `module` here is one build away from the two-copies failure.
862
1212
  vite: viteOrigin(ctx.cwd),
863
1213
  // Rendered as a labelled block by the shared renderer, the same shape the lint role's
864
1214
  // parked rules and the format role's overrides already use.
@@ -868,34 +1218,6 @@ var ViteAdapter = class extends BaseAdapter {
868
1218
  })),
869
1219
  prerequisite: moduleScripts(ctx.cwd)[PREBUILD_SCRIPT_NAME] ?? null
870
1220
  };
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
1221
  }
900
1222
  /**
901
1223
  * Adoption as data, WITHOUT building.
@@ -920,10 +1242,6 @@ var ViteAdapter = class extends BaseAdapter {
920
1242
  }
921
1243
  };
922
1244
  }
923
- async status(ctx) {
924
- const { adopted, preset, conformant, drift, unreadable } = readBuildAdoption(ctx.cwd);
925
- return { adopted, preset, conformant, drift, unreadable };
926
- }
927
1245
  };
928
1246
 
929
1247
  // src/roles/build/register.ts
@@ -935,13 +1253,13 @@ function registerBuild() {
935
1253
  }
936
1254
 
937
1255
  // src/roles/format/adapters/oxfmt/oxfmt.adapter.ts
938
- import { spawnSync as spawnSync4 } from "child_process";
939
- import { existsSync as existsSync12, readFileSync as readFileSync11 } from "fs";
940
- import { join as join14 } from "path";
1256
+ import { spawnSync as spawnSync3 } from "child_process";
1257
+ import { existsSync as existsSync13, readFileSync as readFileSync12 } from "fs";
1258
+ import { join as join15 } from "path";
941
1259
 
942
1260
  // src/core/config/has-source.ts
943
1261
  import { readdirSync } from "fs";
944
- import { extname, join as join8 } from "path";
1262
+ import { extname, join as join9, relative as relative3 } from "path";
945
1263
  var SKIP_DIRECTORIES = /* @__PURE__ */ new Set([
946
1264
  "node_modules",
947
1265
  "dist",
@@ -992,7 +1310,7 @@ function hasSourceFiles(cwd, extensions) {
992
1310
  }
993
1311
  for (const entry of entries) {
994
1312
  if (entry.isDirectory()) {
995
- if (!SKIP_DIRECTORIES.has(entry.name)) queue.push(join8(dir, entry.name));
1313
+ if (!SKIP_DIRECTORIES.has(entry.name)) queue.push(join9(dir, entry.name));
996
1314
  continue;
997
1315
  }
998
1316
  if (wanted.has(extname(entry.name))) return true;
@@ -1000,14 +1318,44 @@ function hasSourceFiles(cwd, extensions) {
1000
1318
  }
1001
1319
  return false;
1002
1320
  }
1321
+ function listSourceFiles(cwd, extensions) {
1322
+ const wanted = new Set(extensions);
1323
+ const queue = [cwd];
1324
+ const found = [];
1325
+ while (queue.length > 0) {
1326
+ const dir = queue.pop();
1327
+ let entries;
1328
+ try {
1329
+ entries = readdirSync(dir, { withFileTypes: true });
1330
+ } catch {
1331
+ continue;
1332
+ }
1333
+ for (const entry of entries) {
1334
+ const full = join9(dir, entry.name);
1335
+ if (entry.isDirectory()) {
1336
+ if (!SKIP_DIRECTORIES.has(entry.name)) queue.push(full);
1337
+ continue;
1338
+ }
1339
+ if (wanted.has(extname(entry.name))) found.push(relative3(cwd, full));
1340
+ }
1341
+ }
1342
+ return found;
1343
+ }
1344
+
1345
+ // src/core/named-scope.ts
1346
+ var TOLERATE_EMPTY_FLAG = "--no-error-on-unmatched-pattern";
1347
+ function scopeFlags(paths, options = []) {
1348
+ if (paths.length === 0 || options.includes(TOLERATE_EMPTY_FLAG)) return [];
1349
+ return [TOLERATE_EMPTY_FLAG];
1350
+ }
1003
1351
 
1004
1352
  // src/core/settings.ts
1005
1353
  var WORKSPACE_ROOT_MARKER = "nx.json";
1006
1354
  var DEFAULT_MAX_DIAGNOSTICS = 100;
1007
1355
 
1008
1356
  // src/core/workspace-prep.ts
1009
- import { existsSync as existsSync8, readFileSync as readFileSync6, writeFileSync } from "fs";
1010
- import { dirname as dirname3, join as join9, relative } from "path";
1357
+ import { existsSync as existsSync9, readFileSync as readFileSync7, writeFileSync } from "fs";
1358
+ import { dirname as dirname3, join as join10, relative as relative4 } from "path";
1011
1359
  var OVERRIDE_KEY = "i18next>typescript";
1012
1360
  var NATIVE_TS_ALIAS = "@typescript/native";
1013
1361
  var WORKSPACE_YAML = "pnpm-workspace.yaml";
@@ -1016,7 +1364,7 @@ var LIST_ITEM = /^(\s*)-\s+(.*?)\s*$/;
1016
1364
  function findWorkspaceRoot(startDir) {
1017
1365
  let dir = startDir;
1018
1366
  for (; ; ) {
1019
- if (existsSync8(join9(dir, WORKSPACE_ROOT_MARKER))) return dir;
1367
+ if (existsSync9(join10(dir, WORKSPACE_ROOT_MARKER))) return dir;
1020
1368
  const parent = dirname3(dir);
1021
1369
  if (parent === dir) return void 0;
1022
1370
  dir = parent;
@@ -1029,9 +1377,9 @@ function declaredNativeTs(pkg) {
1029
1377
  return version || void 0;
1030
1378
  }
1031
1379
  function ensureI18nextSingleton(root, dryRun) {
1032
- const pkgPath = join9(root, "package.json");
1033
- if (!existsSync8(pkgPath)) return void 0;
1034
- const pkg = JSON.parse(readFileSync6(pkgPath, "utf8"));
1380
+ const pkgPath = join10(root, "package.json");
1381
+ if (!existsSync9(pkgPath)) return void 0;
1382
+ const pkg = JSON.parse(readFileSync7(pkgPath, "utf8"));
1035
1383
  const want = declaredNativeTs(pkg);
1036
1384
  if (!want) return void 0;
1037
1385
  const have = pkg.pnpm?.overrides?.[OVERRIDE_KEY];
@@ -1044,10 +1392,10 @@ function ensureI18nextSingleton(root, dryRun) {
1044
1392
  return `pinned ${OVERRIDE_KEY} to ${want} in package.json (i18next singleton)`;
1045
1393
  }
1046
1394
  function ensureReleaseAgeAllowList(root, dryRun) {
1047
- const yamlPath = join9(root, WORKSPACE_YAML);
1048
- if (!existsSync8(yamlPath)) return void 0;
1395
+ const yamlPath = join10(root, WORKSPACE_YAML);
1396
+ if (!existsSync9(yamlPath)) return void 0;
1049
1397
  const own = readOwnPackage().name;
1050
- const lines = readFileSync6(yamlPath, "utf8").split("\n");
1398
+ const lines = readFileSync7(yamlPath, "utf8").split("\n");
1051
1399
  const keyIdx = lines.findIndex((line) => line.replace(/\s+$/, "") === `${RELEASE_AGE_KEY}:`);
1052
1400
  if (keyIdx === -1) return void 0;
1053
1401
  let lastItemIdx = keyIdx;
@@ -1080,13 +1428,13 @@ var ROOT_PRETTIER_CONFIGS = [
1080
1428
  "prettier.config.mjs"
1081
1429
  ];
1082
1430
  function ensureFormatterExclusion(root, moduleDir, dryRun) {
1083
- const rel = relative(root, moduleDir).replaceAll("\\", "/");
1431
+ const rel = relative4(root, moduleDir).replaceAll("\\", "/");
1084
1432
  if (rel === "" || rel.startsWith("..")) return void 0;
1085
- const ignorePath = join9(root, PRETTIER_IGNORE);
1086
- const hasPrettier = existsSync8(ignorePath) || ROOT_PRETTIER_CONFIGS.some((name) => existsSync8(join9(root, name)));
1433
+ const ignorePath = join10(root, PRETTIER_IGNORE);
1434
+ const hasPrettier = existsSync9(ignorePath) || ROOT_PRETTIER_CONFIGS.some((name) => existsSync9(join10(root, name)));
1087
1435
  if (!hasPrettier) return void 0;
1088
1436
  const pattern = `/${rel}/`;
1089
- const existing = existsSync8(ignorePath) ? readFileSync6(ignorePath, "utf8") : "";
1437
+ const existing = existsSync9(ignorePath) ? readFileSync7(ignorePath, "utf8") : "";
1090
1438
  const lines = existing.split("\n");
1091
1439
  if (lines.some((line) => line.trim().replace(/\/$/, "") === pattern.replace(/\/$/, ""))) {
1092
1440
  return void 0;
@@ -1122,10 +1470,10 @@ function ensureWorkspacePrep(opts) {
1122
1470
  function inspectWorkspacePrep(root) {
1123
1471
  const entries = [];
1124
1472
  let pkg = {};
1125
- const pkgPath = join9(root, "package.json");
1126
- if (existsSync8(pkgPath)) {
1473
+ const pkgPath = join10(root, "package.json");
1474
+ if (existsSync9(pkgPath)) {
1127
1475
  try {
1128
- pkg = JSON.parse(readFileSync6(pkgPath, "utf8"));
1476
+ pkg = JSON.parse(readFileSync7(pkgPath, "utf8"));
1129
1477
  } catch {
1130
1478
  pkg = {};
1131
1479
  }
@@ -1139,9 +1487,9 @@ function inspectWorkspacePrep(root) {
1139
1487
  });
1140
1488
  }
1141
1489
  const own = readOwnPackage().name;
1142
- const yamlPath = join9(root, WORKSPACE_YAML);
1143
- if (existsSync8(yamlPath)) {
1144
- const yaml = readFileSync6(yamlPath, "utf8");
1490
+ const yamlPath = join10(root, WORKSPACE_YAML);
1491
+ if (existsSync9(yamlPath)) {
1492
+ const yaml = readFileSync7(yamlPath, "utf8");
1145
1493
  const listed = new RegExp(`^\\s*-\\s*['"]?${own.replace("/", "\\/")}['"]?\\s*$`, "m").test(yaml);
1146
1494
  if (listed) {
1147
1495
  const gated = new RegExp(`^\\s*minimumReleaseAge\\s*:`, "m").test(yaml);
@@ -1228,8 +1576,8 @@ function writesWhenRewritten(name, command) {
1228
1576
  var CHECK_SCRIPT_NAMES = /* @__PURE__ */ new Set(["lint", "format", "check", "test", "typecheck", "verify"]);
1229
1577
 
1230
1578
  // src/roles/format/inherited-ignores.ts
1231
- import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
1232
- import { join as join10 } from "path";
1579
+ import { existsSync as existsSync10, readFileSync as readFileSync8 } from "fs";
1580
+ import { join as join11 } from "path";
1233
1581
  var ROOT_IGNORE_FILE = ".prettierignore";
1234
1582
  function isPattern(line) {
1235
1583
  const trimmed = line.trim();
@@ -1244,11 +1592,11 @@ function toModulePattern(pattern) {
1244
1592
  }
1245
1593
  function inheritedIgnorePatterns(workspaceRoot) {
1246
1594
  if (!workspaceRoot) return [];
1247
- const path = join10(workspaceRoot, ROOT_IGNORE_FILE);
1248
- if (!existsSync9(path)) return [];
1595
+ const path = join11(workspaceRoot, ROOT_IGNORE_FILE);
1596
+ if (!existsSync10(path)) return [];
1249
1597
  let contents;
1250
1598
  try {
1251
- contents = readFileSync7(path, "utf8");
1599
+ contents = readFileSync8(path, "utf8");
1252
1600
  } catch {
1253
1601
  return [];
1254
1602
  }
@@ -1335,13 +1683,13 @@ function formatPresetFor(preset) {
1335
1683
  }
1336
1684
 
1337
1685
  // src/roles/format/prettier-config.ts
1338
- import { existsSync as existsSync10, readFileSync as readFileSync9 } from "fs";
1339
- import { join as join12 } from "path";
1686
+ import { existsSync as existsSync11, readFileSync as readFileSync10 } from "fs";
1687
+ import { join as join13 } from "path";
1340
1688
 
1341
1689
  // src/roles/format/resolve-oxfmt.ts
1342
- import { readFileSync as readFileSync8 } from "fs";
1690
+ import { readFileSync as readFileSync9 } from "fs";
1343
1691
  import { createRequire as createRequire2 } from "module";
1344
- import { dirname as dirname4, join as join11 } from "path";
1692
+ import { dirname as dirname4, join as join12 } from "path";
1345
1693
  function resolveOxfmt(cwd) {
1346
1694
  return resolveBin(cwd, "oxfmt") ?? binFromOwnInstall("oxfmt", "oxfmt");
1347
1695
  }
@@ -1370,8 +1718,8 @@ function configSchema() {
1370
1718
  function readConfigSchema() {
1371
1719
  try {
1372
1720
  const manifest = createRequire2(import.meta.url).resolve("oxfmt/package.json");
1373
- const schemaPath = join11(dirname4(manifest), "configuration_schema.json");
1374
- return JSON.parse(readFileSync8(schemaPath, "utf8"));
1721
+ const schemaPath = join12(dirname4(manifest), "configuration_schema.json");
1722
+ return JSON.parse(readFileSync9(schemaPath, "utf8"));
1375
1723
  } catch {
1376
1724
  return void 0;
1377
1725
  }
@@ -1402,14 +1750,14 @@ function toOxfmtOverrides(value) {
1402
1750
  return { overrides, unresolved };
1403
1751
  }
1404
1752
  function readPrettierSettings(cwd) {
1405
- const file = PRETTIER_CONFIG_FILES.find((name) => existsSync10(join12(cwd, name)));
1753
+ const file = PRETTIER_CONFIG_FILES.find((name) => existsSync11(join13(cwd, name)));
1406
1754
  if (!file) return { options: {}, unresolved: [] };
1407
1755
  if (/\.(js|cjs|mjs)$/.test(file)) {
1408
1756
  return { options: {}, file, unresolved: [`${file} is JavaScript, so it was not executed`] };
1409
1757
  }
1410
1758
  let parsed;
1411
1759
  try {
1412
- parsed = parseJsonc(readFileSync9(join12(cwd, file), "utf8"), file);
1760
+ parsed = parseJsonc(readFileSync10(join13(cwd, file), "utf8"), file);
1413
1761
  } catch {
1414
1762
  return { options: {}, file, unresolved: [`${file} could not be parsed`] };
1415
1763
  }
@@ -1439,8 +1787,8 @@ function readPrettierSettings(cwd) {
1439
1787
  }
1440
1788
 
1441
1789
  // src/roles/format/read-adoption.ts
1442
- import { existsSync as existsSync11, readFileSync as readFileSync10 } from "fs";
1443
- import { join as join13 } from "path";
1790
+ import { existsSync as existsSync12, readFileSync as readFileSync11 } from "fs";
1791
+ import { join as join14 } from "path";
1444
1792
  var NOT_ADOPTED2 = (configFile, unreadable = null) => ({
1445
1793
  configFile,
1446
1794
  preset: null,
@@ -1456,11 +1804,11 @@ function sameValue(a, b) {
1456
1804
  return JSON.stringify(a) === JSON.stringify(b);
1457
1805
  }
1458
1806
  function readFormatAdoption(cwd) {
1459
- const path = join13(cwd, FORMAT_CONFIG_FILE);
1460
- if (!existsSync11(path)) return NOT_ADOPTED2(null);
1807
+ const path = join14(cwd, FORMAT_CONFIG_FILE);
1808
+ if (!existsSync12(path)) return NOT_ADOPTED2(null);
1461
1809
  let parsed;
1462
1810
  try {
1463
- parsed = parseJsonc(readFileSync10(path, "utf8"), FORMAT_CONFIG_FILE);
1811
+ parsed = parseJsonc(readFileSync11(path, "utf8"), FORMAT_CONFIG_FILE);
1464
1812
  } catch (error) {
1465
1813
  const reason = error instanceof Error ? error.message : String(error);
1466
1814
  return NOT_ADOPTED2(FORMAT_CONFIG_FILE, `${FORMAT_CONFIG_FILE} could not be parsed (${reason})`);
@@ -1527,6 +1875,22 @@ var OxfmtAdapter = class extends BaseAdapter {
1527
1875
  appliesTo(_preset) {
1528
1876
  return true;
1529
1877
  }
1878
+ /**
1879
+ * The preset this module COMMITTED to, read from the `$sentinel` block it writes.
1880
+ *
1881
+ * Every other role implements this and format did not, so it was the only one answering from
1882
+ * dependency detection alone. On an adopted Nest module whose own `.oxfmtrc.json` records
1883
+ * `"preset": "nest"`, `--inspect` reported `flavour=node` — the module knew, and the role did
1884
+ * not pass it on. Same command, four rows, two different answers about what the module is.
1885
+ *
1886
+ * It reads the RECORD rather than re-deriving: the block is what `--init` wrote, so it is the
1887
+ * module's declaration, and a re-derivation from hoisted dependencies is exactly the wrong
1888
+ * answer this exists to avoid.
1889
+ */
1890
+ declaredPreset(cwd) {
1891
+ const declared = readFormatAdoption(cwd).preset;
1892
+ return declared !== null && PRESET_NAMES.includes(declared) ? declared : void 0;
1893
+ }
1530
1894
  plan(context) {
1531
1895
  if (!hasSourceFiles(context.cwd, FORMATTABLE_EXTENSIONS)) {
1532
1896
  return {
@@ -1569,7 +1933,7 @@ var OxfmtAdapter = class extends BaseAdapter {
1569
1933
  manifestOperation(context.cwd, this.formatScripts(context.cwd))
1570
1934
  ];
1571
1935
  const prettierConfigs = PRETTIER_CONFIG_FILES.filter(
1572
- (name) => existsSync12(join14(context.cwd, name))
1936
+ (name) => existsSync13(join15(context.cwd, name))
1573
1937
  );
1574
1938
  for (const name of prettierConfigs) operations.push({ kind: "delete", path: name });
1575
1939
  const removableDeps = this.modulePrettierDependencies(context.cwd);
@@ -1628,7 +1992,7 @@ var OxfmtAdapter = class extends BaseAdapter {
1628
1992
  async afterInit(ctx) {
1629
1993
  const oxfmt = resolveOxfmt(ctx.cwd);
1630
1994
  if (!oxfmt) return;
1631
- const pass = spawnSync4(oxfmt, ["--write", "."], { cwd: ctx.cwd, encoding: "utf8" });
1995
+ const pass = spawnSync3(oxfmt, ["--write", "."], { cwd: ctx.cwd, encoding: "utf8" });
1632
1996
  process.stderr.write(` ${palette(process.stderr).dim(FORMATTER_DIFFERENCES)}
1633
1997
  `);
1634
1998
  if (pass.status === 0) return;
@@ -1691,10 +2055,14 @@ var OxfmtAdapter = class extends BaseAdapter {
1691
2055
  const { options, paths } = splitToolArgs(ctx.cwd, ctx.toolArgs, {
1692
2056
  valueFlags: OXFMT_VALUE_FLAGS
1693
2057
  });
1694
- const result = spawnSync4(oxfmt, [mode, ...options, ...paths.length > 0 ? paths : ["."]], {
1695
- cwd: ctx.cwd,
1696
- stdio: "inherit"
1697
- });
2058
+ const result = spawnSync3(
2059
+ oxfmt,
2060
+ [mode, ...scopeFlags(paths, options), ...options, ...paths.length > 0 ? paths : ["."]],
2061
+ {
2062
+ cwd: ctx.cwd,
2063
+ stdio: "inherit"
2064
+ }
2065
+ );
1698
2066
  if (result.error) {
1699
2067
  process.stderr.write(
1700
2068
  `sentinel format(oxfmt): could not run oxfmt (${result.error.message})
@@ -1769,7 +2137,7 @@ var OxfmtAdapter = class extends BaseAdapter {
1769
2137
  );
1770
2138
  return { ok: false, code: 1, metrics: { ...base, unformatted: null } };
1771
2139
  }
1772
- const result = spawnSync4(oxfmt, ["--list-different", "."], { cwd: ctx.cwd, encoding: "utf8" });
2140
+ const result = spawnSync3(oxfmt, ["--list-different", "."], { cwd: ctx.cwd, encoding: "utf8" });
1773
2141
  if (result.error) {
1774
2142
  process.stderr.write(
1775
2143
  `sentinel format(oxfmt): could not run oxfmt (${result.error.message})
@@ -1805,7 +2173,7 @@ var OxfmtAdapter = class extends BaseAdapter {
1805
2173
  modulePrettierDependencies(cwd) {
1806
2174
  const isPrettierPackage = (name) => name === "prettier" || name.startsWith("prettier-plugin-") || name.startsWith("@prettier/");
1807
2175
  try {
1808
- const manifest = JSON.parse(readFileSync11(join14(cwd, "package.json"), "utf8"));
2176
+ const manifest = JSON.parse(readFileSync12(join15(cwd, "package.json"), "utf8"));
1809
2177
  return Object.keys(manifest.devDependencies ?? {}).filter(isPrettierPackage).map((name) => ["devDependencies", name]);
1810
2178
  } catch {
1811
2179
  return [];
@@ -1884,15 +2252,122 @@ function registerFormat() {
1884
2252
  register(new OxfmtAdapter());
1885
2253
  setDefaultRunner("format", "oxfmt");
1886
2254
  }
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);
2255
+
2256
+ // src/roles/lint/adapters/oxlint/oxlint.adapter.ts
2257
+ import { spawnSync as spawnSync4 } from "child_process";
2258
+ import { existsSync as existsSync21, readFileSync as readFileSync20, rmSync, writeFileSync as writeFileSync2 } from "fs";
2259
+ import { join as join25 } from "path";
2260
+
2261
+ // src/core/config/deferred-rules.ts
2262
+ function deferredRuleNames(rules) {
2263
+ return rules.map((entry) => entry.rule);
2264
+ }
2265
+
2266
+ // src/core/fix-report.ts
2267
+ import { statSync } from "fs";
2268
+ import { join as join16 } from "path";
2269
+ var CONFIG_REFUSED = /failed to parse .*config|invalid config file/i;
2270
+ function readFailure(output) {
2271
+ const lines = output.split("\n").map((line2) => line2.trim());
2272
+ if (!lines.some((line2) => CONFIG_REFUSED.test(line2))) return void 0;
2273
+ const detail = lines.find((line2) => /invalid config file/i.test(line2));
2274
+ const line = (detail ?? lines.find((entry) => entry !== "") ?? "").replace(/^x\s*/, "");
2275
+ return line.split(/:\s*Failed to parse config/i)[0] ?? line;
2276
+ }
2277
+ function fingerprint(path) {
2278
+ try {
2279
+ const stat = statSync(path);
2280
+ return `${stat.size}:${stat.mtimeMs}`;
2281
+ } catch {
2282
+ return void 0;
2283
+ }
2284
+ }
2285
+ function snapshotFiles(cwd, extensions) {
2286
+ const snapshot = /* @__PURE__ */ new Map();
2287
+ for (const file of listSourceFiles(cwd, extensions)) {
2288
+ const mark = fingerprint(join16(cwd, file));
2289
+ if (mark !== void 0) snapshot.set(file, mark);
2290
+ }
2291
+ return snapshot;
2292
+ }
2293
+ function changedSince(cwd, before, extensions) {
2294
+ const changed = [];
2295
+ for (const file of listSourceFiles(cwd, extensions)) {
2296
+ const now = fingerprint(join16(cwd, file));
2297
+ if (now === void 0) continue;
2298
+ if (before.get(file) !== now) changed.push(file);
2299
+ }
2300
+ return changed.sort();
2301
+ }
2302
+ var NAMED_IN_REPORT = 5;
2303
+ function describeFixOutcome(outcome, toolLabel) {
2304
+ if (outcome.failure !== void 0) {
2305
+ return `the ${toolLabel} autofix could not run, so nothing was fixed and this module has not been checked: ${outcome.failure}`;
2306
+ }
2307
+ if (outcome.changed.length === 0) return `the ${toolLabel} autofix changed nothing`;
2308
+ const named = outcome.changed.slice(0, NAMED_IN_REPORT).join(", ");
2309
+ const rest = outcome.changed.length - NAMED_IN_REPORT;
2310
+ const listed = rest > 0 ? `${named} and ${rest} more` : named;
2311
+ 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.`;
2312
+ }
2313
+
2314
+ // src/roles/lint/module-baseline.ts
2315
+ import { existsSync as existsSync14, readFileSync as readFileSync13 } from "fs";
2316
+ import { join as join17 } from "path";
2317
+ var LINT_BASELINE_FILE = ".oxlintrc.baseline.json";
2318
+ var LINT_MEASURE_FILE = ".oxlintrc.measure.json";
2319
+ var LINT_BASELINE_SPECIFIER = `./${LINT_BASELINE_FILE}`;
2320
+ function holdsFrom(errorCounts) {
2321
+ return [...errorCounts].map(([rule, count]) => ({ rule, count })).sort((left, right) => right.count - left.count || left.rule.localeCompare(right.rule));
2322
+ }
2323
+ function renderBaseline(holds, version) {
2324
+ const entries = holds.map(({ rule }) => ` ${JSON.stringify(rule)}: "warn"`).join(",\n");
2325
+ return [
2326
+ "{",
2327
+ ` // Written by @hublo/sentinel@${version}. Do not edit by hand: \`sentinel --init --lint\``,
2328
+ " // regenerates it from a fresh measurement, and a rule you have since fixed drops out",
2329
+ " // and returns to `error` on its own.",
2330
+ " //",
2331
+ " // These rules are held at `warn` because this module ALREADY violated them when it",
2332
+ " // adopted. They still run and still report, so the build cannot fail on them.",
2333
+ " //",
2334
+ " // The hold is per RULE, not per occurrence: oxlint cannot freeze a specific list of",
2335
+ " // violations, so a NEW violation of one of these rules is covered too, and will only",
2336
+ " // warn. That is the cost of adopting without a red build, and the reason to clear this",
2337
+ " // file rather than live with it.",
2338
+ " //",
2339
+ " // `sentinel --inspect --lint` lists what is held; `sentinel --run --lint --json` counts",
2340
+ " // what is left, per rule. Fix them and re-run `--init --lint` to get the severity back.",
2341
+ ' "rules": {',
2342
+ entries,
2343
+ " }",
2344
+ "}",
2345
+ ""
2346
+ ].join("\n");
2347
+ }
2348
+ function extendsWithBaseline(current, hasHolds) {
2349
+ const withoutBaseline = current.filter((entry) => entry !== LINT_BASELINE_SPECIFIER);
2350
+ return hasHolds ? [...withoutBaseline, LINT_BASELINE_SPECIFIER] : withoutBaseline;
2351
+ }
2352
+ function describeHolds(holds) {
2353
+ if (holds.length === 0) return "";
2354
+ const total = holds.reduce((sum, hold) => sum + hold.count, 0);
2355
+ const listed = holds.slice(0, 5).map(({ rule, count }) => `${rule} (${count})`).join(", ");
2356
+ const rest = holds.length > 5 ? `, and ${holds.length - 5} more` : "";
2357
+ 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`;
2358
+ }
2359
+ function heldRules(cwd) {
2360
+ const path = join17(cwd, LINT_BASELINE_FILE);
2361
+ if (!existsSync14(path)) return [];
2362
+ try {
2363
+ const parsed = parseJsonc(
2364
+ readFileSync13(path, "utf8"),
2365
+ LINT_BASELINE_FILE
2366
+ );
2367
+ return Object.keys(parsed.rules ?? {});
2368
+ } catch {
2369
+ return [];
2370
+ }
1896
2371
  }
1897
2372
 
1898
2373
  // src/roles/lint/config-policy.ts
@@ -1907,8 +2382,10 @@ function isSentinelPreset(entry) {
1907
2382
  }
1908
2383
  function extendsWithPreset(current, variant) {
1909
2384
  const own = presetPath(variant);
1910
- const others = current.filter((entry) => entry !== own && !isSentinelPreset(entry));
1911
- return [own, ...new Set(others)];
2385
+ const others = [...new Set(current.filter((entry) => entry !== own && !isSentinelPreset(entry)))];
2386
+ const baseline = others.filter((entry) => entry === LINT_BASELINE_SPECIFIER);
2387
+ const rest = others.filter((entry) => entry !== LINT_BASELINE_SPECIFIER);
2388
+ return [own, ...rest, ...baseline];
1912
2389
  }
1913
2390
  function presetVariant(preset, cwd) {
1914
2391
  const normalised = cwd.replaceAll("\\", "/");
@@ -1932,46 +2409,6 @@ function lintTarget() {
1932
2409
  return { cache: true, inputs: ["default", "^default", `{projectRoot}/${LINT_CONFIG_FILE}`] };
1933
2410
  }
1934
2411
 
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
2412
  // src/roles/lint/presets/nest.json
1976
2413
  var nest_default = {
1977
2414
  plugins: ["typescript", "jest", "import", "unicorn", "oxc", "node"],
@@ -2311,6 +2748,7 @@ var nest_default = {
2311
2748
  "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
2749
  "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
2750
  "no-octal": "no oxlint equivalent",
2751
+ "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
2752
  "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
2753
  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
2754
  "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 +3702,51 @@ var react_default = {
3264
3702
  uncovered: {
3265
3703
  "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
3704
  "no-octal": "no oxlint equivalent",
3705
+ "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
3706
  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
3707
  }
3269
3708
  };
3270
3709
 
3710
+ // src/roles/lint/presets/shared.json
3711
+ var shared_default = {
3712
+ rules: {
3713
+ "no-var": "error",
3714
+ "prefer-const": [
3715
+ "error",
3716
+ {
3717
+ destructuring: "any",
3718
+ ignoreReadBeforeAssign: false
3719
+ }
3720
+ ],
3721
+ "prefer-rest-params": "error",
3722
+ "prefer-spread": "error",
3723
+ "typescript/ban-ts-comment": "error",
3724
+ "typescript/no-array-constructor": "error",
3725
+ "typescript/no-duplicate-enum-values": "error",
3726
+ "typescript/no-empty-object-type": "error",
3727
+ "typescript/no-extra-non-null-assertion": "error",
3728
+ "typescript/no-misused-new": "error",
3729
+ "typescript/no-namespace": "error",
3730
+ "typescript/no-non-null-asserted-optional-chain": "error",
3731
+ "typescript/no-this-alias": "error",
3732
+ "typescript/no-unnecessary-type-constraint": "error",
3733
+ "typescript/no-unsafe-declaration-merging": "error",
3734
+ "typescript/no-unsafe-function-type": "error",
3735
+ "typescript/no-unused-expressions": [
3736
+ "error",
3737
+ {
3738
+ allowShortCircuit: false,
3739
+ allowTaggedTemplates: false,
3740
+ allowTernary: false
3741
+ }
3742
+ ],
3743
+ "typescript/no-wrapper-object-types": "error",
3744
+ "typescript/prefer-as-const": "error",
3745
+ "typescript/prefer-namespace-keyword": "error",
3746
+ "typescript/triple-slash-reference": "error"
3747
+ }
3748
+ };
3749
+
3271
3750
  // src/roles/lint/presets/svelte.json
3272
3751
  var svelte_default = {
3273
3752
  plugins: ["typescript", "oxc"],
@@ -3606,7 +4085,7 @@ var tools_default = {
3606
4085
  };
3607
4086
 
3608
4087
  // src/roles/lint/preset-data.ts
3609
- var BASE_RULES = base_default2.rules;
4088
+ var SHARED_RULES = shared_default.rules;
3610
4089
  var PRESET_FILES = {
3611
4090
  nest: nest_default,
3612
4091
  node: node_default,
@@ -3628,15 +4107,18 @@ function hasLintPreset(preset) {
3628
4107
  function presetFile(preset) {
3629
4108
  return VARIANT_FILES[preset] ?? PRESET_FILES[preset] ?? node_default;
3630
4109
  }
3631
- function baseRules() {
3632
- return BASE_RULES;
4110
+ function sharedRules() {
4111
+ return SHARED_RULES;
3633
4112
  }
3634
4113
  function presetRules(preset) {
3635
4114
  return presetFile(preset).rules;
3636
4115
  }
4116
+ function uncoveredFor(preset) {
4117
+ return Object.entries(presetFile(preset).uncovered ?? {}).map(([rule, reason]) => ({ rule, reason })).sort((left, right) => left.rule.localeCompare(right.rule));
4118
+ }
3637
4119
 
3638
4120
  // src/roles/lint/rule-data.ts
3639
- var baseRules2 = baseRules();
4121
+ var sharedRules2 = sharedRules();
3640
4122
  function isDecision(value) {
3641
4123
  return typeof value === "object" && value !== null && !Array.isArray(value);
3642
4124
  }
@@ -3663,7 +4145,7 @@ function validate(preset, rules) {
3663
4145
  }
3664
4146
  }
3665
4147
  function layered(preset) {
3666
- const base = baseRules();
4148
+ const base = sharedRules();
3667
4149
  const layer = presetRules(preset);
3668
4150
  validate("base", base);
3669
4151
  validate(preset, layer);
@@ -3702,60 +4184,14 @@ function downgradedRulesFor(preset) {
3702
4184
  }
3703
4185
 
3704
4186
  // 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
4187
+ import { existsSync as existsSync15, readFileSync as readFileSync14 } from "fs";
4188
+ import { isAbsolute as isAbsolute2, join as join18, resolve as resolve3 } from "path";
3753
4189
  function ruleCount(cwd, specifier) {
3754
4190
  const path = isAbsolute2(specifier) ? specifier : resolve3(cwd, specifier);
3755
- if (!existsSync13(path)) return void 0;
4191
+ if (!existsSync15(path)) return void 0;
3756
4192
  try {
3757
4193
  const parsed = parseJsonc(
3758
- readFileSync12(path, "utf8"),
4194
+ readFileSync14(path, "utf8"),
3759
4195
  specifier
3760
4196
  );
3761
4197
  return Object.keys(parsed.rules ?? {}).length;
@@ -3775,7 +4211,7 @@ function extraLayers(cwd, extendsList) {
3775
4211
  function committedExtendsList(cwd, configFile) {
3776
4212
  try {
3777
4213
  const parsed = parseJsonc(
3778
- readFileSync12(join15(cwd, configFile), "utf8"),
4214
+ readFileSync14(join18(cwd, configFile), "utf8"),
3779
4215
  configFile
3780
4216
  );
3781
4217
  return Array.isArray(parsed.extends) ? parsed.extends.filter((entry) => typeof entry === "string") : [];
@@ -3834,8 +4270,8 @@ function errorCountsByConfigRule(stdout) {
3834
4270
  }
3835
4271
 
3836
4272
  // src/core/config/read-adoption.ts
3837
- import { existsSync as existsSync15, readFileSync as readFileSync14 } from "fs";
3838
- import { join as join17 } from "path";
4273
+ import { existsSync as existsSync17, readFileSync as readFileSync16 } from "fs";
4274
+ import { join as join20 } from "path";
3839
4275
 
3840
4276
  // src/core/config/owned-keys.ts
3841
4277
  function presetOwnedKeys(config, permitted, presetSets) {
@@ -3850,12 +4286,12 @@ function localOnlyKeys(config, permitted, presetSets) {
3850
4286
  }
3851
4287
 
3852
4288
  // src/core/config/resolve-config-target.ts
3853
- import { existsSync as existsSync14, readFileSync as readFileSync13 } from "fs";
3854
- import { join as join16 } from "path";
4289
+ import { existsSync as existsSync16, readFileSync as readFileSync15 } from "fs";
4290
+ import { join as join19 } from "path";
3855
4291
  function readExtends(absolutePath) {
3856
4292
  let parsed;
3857
4293
  try {
3858
- parsed = parseJsonc(readFileSync13(absolutePath, "utf8"), absolutePath);
4294
+ parsed = parseJsonc(readFileSync15(absolutePath, "utf8"), absolutePath);
3859
4295
  } catch {
3860
4296
  return [];
3861
4297
  }
@@ -3869,8 +4305,8 @@ function resolveConfigTarget(moduleDir, { candidates, markers, fallback }) {
3869
4305
  let existing;
3870
4306
  let existingExtendsSomething = false;
3871
4307
  for (const candidate of candidates) {
3872
- const absolutePath = join16(moduleDir, candidate);
3873
- if (!existsSync14(absolutePath)) continue;
4308
+ const absolutePath = join19(moduleDir, candidate);
4309
+ if (!existsSync16(absolutePath)) continue;
3874
4310
  const chain = readExtends(absolutePath);
3875
4311
  if (existing === void 0) {
3876
4312
  existing = candidate;
@@ -3903,12 +4339,12 @@ var NOT_ADOPTED3 = (configFile, unreadable = null) => ({
3903
4339
  });
3904
4340
  function readAdoption(cwd, options) {
3905
4341
  const target = resolveConfigTarget(cwd, options);
3906
- if (target.reason === "none" || !existsSync15(join17(cwd, target.path))) {
4342
+ if (target.reason === "none" || !existsSync17(join20(cwd, target.path))) {
3907
4343
  return NOT_ADOPTED3(target.reason === "none" ? null : target.path);
3908
4344
  }
3909
4345
  let parsed;
3910
4346
  try {
3911
- parsed = parseJsonc(readFileSync14(join17(cwd, target.path), "utf8"), target.path);
4347
+ parsed = parseJsonc(readFileSync16(join20(cwd, target.path), "utf8"), target.path);
3912
4348
  } catch (error) {
3913
4349
  const reason = error instanceof Error ? error.message : String(error);
3914
4350
  return NOT_ADOPTED3(target.path, `${target.path} could not be parsed (${reason})`);
@@ -3942,9 +4378,9 @@ function readLintAdoption(cwd) {
3942
4378
  }
3943
4379
 
3944
4380
  // src/roles/lint/resolve-oxlint.ts
3945
- import { existsSync as existsSync16 } from "fs";
4381
+ import { existsSync as existsSync18 } from "fs";
3946
4382
  import { createRequire as createRequire3 } from "module";
3947
- import { delimiter as delimiter2, dirname as dirname5, join as join18 } from "path";
4383
+ import { delimiter as delimiter2, dirname as dirname5, join as join21 } from "path";
3948
4384
  import { fileURLToPath as fileURLToPath2 } from "url";
3949
4385
  var PACKAGE_OF = {
3950
4386
  oxlint: "oxlint",
@@ -3969,32 +4405,31 @@ function tsgolintShim(cwd) {
3969
4405
  for (const owner of ["oxlint-tsgolint", "oxlint"]) {
3970
4406
  try {
3971
4407
  const packageDir = dirname5(require3.resolve(`${owner}/package.json`));
3972
- candidates.push(join18(packageDir, "node_modules", ".bin", "tsgolint"));
3973
- candidates.push(join18(packageDir, "..", ".bin", "tsgolint"));
4408
+ candidates.push(join21(packageDir, "node_modules", ".bin", "tsgolint"));
4409
+ candidates.push(join21(packageDir, "..", ".bin", "tsgolint"));
3974
4410
  } catch {
3975
4411
  }
3976
4412
  }
3977
4413
  try {
3978
4414
  const ownRoot = dirname5(require3.resolve("@hublo/sentinel/package.json"));
3979
- candidates.push(join18(ownRoot, "node_modules", ".bin", "tsgolint"));
4415
+ candidates.push(join21(ownRoot, "node_modules", ".bin", "tsgolint"));
3980
4416
  } catch {
3981
4417
  candidates.push(resolveBin(dirname5(fileURLToPath2(import.meta.url)), "tsgolint") ?? "");
3982
4418
  }
3983
- return candidates.find((candidate) => candidate !== "" && existsSync16(candidate));
4419
+ return candidates.find((candidate) => candidate !== "" && existsSync18(candidate));
3984
4420
  }
3985
4421
  function oxlintSearchPath(cwd) {
3986
4422
  return binSearchPath(cwd);
3987
4423
  }
3988
4424
 
3989
4425
  // src/roles/lint/adapters/oxlint/plan.ts
3990
- import { existsSync as existsSync18, readFileSync as readFileSync17 } from "fs";
3991
- import { join as join21 } from "path";
4426
+ import { existsSync as existsSync20, readFileSync as readFileSync19 } from "fs";
4427
+ import { join as join24 } from "path";
3992
4428
 
3993
4429
  // 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;
4430
+ import { existsSync as existsSync19, readFileSync as readFileSync17 } from "fs";
4431
+ import { join as join22 } from "path";
4432
+ import { parseSync as parseSync2 } from "oxc-parser";
3998
4433
  var OPAQUE_SOURCE = /\b(includeIgnoreFile)\s*\([^)]*\)/g;
3999
4434
  function toOxlintPattern(pattern) {
4000
4435
  return pattern.replace(/^\.\//, "");
@@ -4005,24 +4440,71 @@ function readRootEslintIgnores(root) {
4005
4440
  return { patterns: patterns.filter((pattern) => pattern.startsWith("**/")), unresolved };
4006
4441
  }
4007
4442
  function readEslintIgnores(cwd) {
4008
- const config = ESLINT_CONFIG_FILES.map((name) => join19(cwd, name)).find((path) => existsSync17(path));
4443
+ const config = ESLINT_CONFIG_FILES.map((name) => join22(cwd, name)).find((path) => existsSync19(path));
4009
4444
  if (!config) return { patterns: [], unresolved: [] };
4010
4445
  let source;
4011
4446
  try {
4012
- source = readFileSync15(config, "utf8");
4447
+ source = readFileSync17(config, "utf8");
4013
4448
  } catch {
4014
4449
  return { patterns: [], unresolved: [] };
4015
4450
  }
4016
4451
  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));
4452
+ const unresolved = new Set([...source.matchAll(OPAQUE_SOURCE)].map((match) => match[0]));
4453
+ const { program, errors } = parseSync2(config, source, { sourceType: "module" });
4454
+ if (errors.length > 0) {
4455
+ return { patterns: [], unresolved: [`${config} could not be parsed`] };
4456
+ }
4457
+ walk(program, (node) => {
4458
+ if (node.type === "CallExpression" && calleeName(node) === "globalIgnores") {
4459
+ for (const literal of stringLiterals(firstArrayArgument(node))) {
4460
+ patterns.add(toOxlintPattern(literal));
4021
4461
  }
4462
+ return;
4022
4463
  }
4464
+ if (node.type !== "ObjectExpression") return;
4465
+ const keys = objectKeys(node);
4466
+ if (!keys.includes("ignores")) return;
4467
+ const values = arrayValues(node, "ignores");
4468
+ if (keys.length > 1) {
4469
+ unresolved.add(
4470
+ `ignores scoped to one config block (with ${keys.filter((k) => k !== "ignores").join(", ")}): ${values.join(", ")} \u2014 exempt from those RULES, not excluded from linting`
4471
+ );
4472
+ return;
4473
+ }
4474
+ for (const literal of values) patterns.add(toOxlintPattern(literal));
4475
+ });
4476
+ return { patterns: [...patterns], unresolved: [...unresolved] };
4477
+ }
4478
+ function walk(node, visit) {
4479
+ if (!node || typeof node !== "object") return;
4480
+ const current = node;
4481
+ if (typeof current.type === "string") visit(current);
4482
+ for (const key of Object.keys(current)) {
4483
+ const value = current[key];
4484
+ if (Array.isArray(value)) for (const entry of value) walk(entry, visit);
4485
+ else if (value && typeof value === "object") walk(value, visit);
4023
4486
  }
4024
- const unresolved = [...source.matchAll(OPAQUE_SOURCE)].map((match) => match[0]);
4025
- return { patterns: [...patterns], unresolved: [...new Set(unresolved)] };
4487
+ }
4488
+ function calleeName(node) {
4489
+ const callee = node.callee;
4490
+ return callee?.type === "Identifier" ? callee.name : void 0;
4491
+ }
4492
+ function firstArrayArgument(node) {
4493
+ const args = node.arguments ?? [];
4494
+ return args.find((argument) => argument?.type === "ArrayExpression");
4495
+ }
4496
+ function stringLiterals(array) {
4497
+ const elements = array?.elements ?? [];
4498
+ return elements.filter((element) => element?.type === "Literal" && typeof element.value === "string").map((element) => element.value);
4499
+ }
4500
+ function objectKeys(node) {
4501
+ const properties = node.properties ?? [];
4502
+ return properties.filter((property) => property?.type === "Property" && !property.computed).map((property) => property.key?.name ?? String(property.key?.value ?? "")).filter(Boolean);
4503
+ }
4504
+ function arrayValues(node, name) {
4505
+ const properties = node.properties ?? [];
4506
+ const found = properties.find((property) => property.key?.name === name);
4507
+ return stringLiterals(found?.value);
4026
4508
  }
4027
4509
 
4028
4510
  // src/roles/lint/lint-script.ts
@@ -4063,8 +4545,8 @@ function lintPresetFor(preset) {
4063
4545
  }
4064
4546
 
4065
4547
  // 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";
4548
+ import { readdirSync as readdirSync2, readFileSync as readFileSync18, statSync as statSync2 } from "fs";
4549
+ import { join as join23, relative as relative5 } from "path";
4068
4550
  var SOURCE_EXTENSIONS = [
4069
4551
  ".ts",
4070
4552
  ".tsx",
@@ -4111,10 +4593,10 @@ function* sourceFiles(dir) {
4111
4593
  return;
4112
4594
  }
4113
4595
  for (const entry of entries) {
4114
- const full = join20(dir, entry);
4596
+ const full = join23(dir, entry);
4115
4597
  let isDirectory;
4116
4598
  try {
4117
- isDirectory = statSync(full).isDirectory();
4599
+ isDirectory = statSync2(full).isDirectory();
4118
4600
  } catch {
4119
4601
  continue;
4120
4602
  }
@@ -4131,7 +4613,7 @@ function findSuppressionRenames(cwd, renames) {
4131
4613
  for (const file of sourceFiles(cwd)) {
4132
4614
  let content;
4133
4615
  try {
4134
- content = readFileSync16(file, "utf8");
4616
+ content = readFileSync18(file, "utf8");
4135
4617
  } catch {
4136
4618
  continue;
4137
4619
  }
@@ -4155,7 +4637,7 @@ function findSuppressionRenames(cwd, renames) {
4155
4637
  }
4156
4638
  if (hits.length === 0) return;
4157
4639
  found.push({
4158
- file: relative2(cwd, file),
4640
+ file: relative5(cwd, file),
4159
4641
  line: index + 1,
4160
4642
  from: line,
4161
4643
  to: line.replace(rules, renamed.join(", ")),
@@ -4259,7 +4741,7 @@ function plan2(context) {
4259
4741
  keys: removableDeps
4260
4742
  });
4261
4743
  }
4262
- const eslintConfigs = ESLINT_CONFIG_FILES.filter((name) => existsSync18(join21(context.cwd, name)));
4744
+ const eslintConfigs = ESLINT_CONFIG_FILES.filter((name) => existsSync20(join24(context.cwd, name)));
4263
4745
  for (const name of eslintConfigs) operations.push({ kind: "delete", path: name });
4264
4746
  operations.push(
4265
4747
  ...nxTargetOperations({
@@ -4357,7 +4839,7 @@ function lintScripts(cwd) {
4357
4839
  function committedExtends(cwd) {
4358
4840
  try {
4359
4841
  const parsed = parseJsonc(
4360
- readFileSync17(join21(cwd, LINT_CONFIG_FILE), "utf8"),
4842
+ readFileSync19(join24(cwd, LINT_CONFIG_FILE), "utf8"),
4361
4843
  LINT_CONFIG_FILE
4362
4844
  );
4363
4845
  return Array.isArray(parsed.extends) ? parsed.extends.filter((entry) => typeof entry === "string") : [];
@@ -4368,7 +4850,7 @@ function committedExtends(cwd) {
4368
4850
  function committedIgnorePatterns(cwd) {
4369
4851
  try {
4370
4852
  const parsed = parseJsonc(
4371
- readFileSync17(join21(cwd, LINT_CONFIG_FILE), "utf8"),
4853
+ readFileSync19(join24(cwd, LINT_CONFIG_FILE), "utf8"),
4372
4854
  LINT_CONFIG_FILE
4373
4855
  );
4374
4856
  return Array.isArray(parsed.ignorePatterns) ? parsed.ignorePatterns.filter((entry) => typeof entry === "string") : [];
@@ -4380,7 +4862,7 @@ function moduleEslintDependencies(cwd) {
4380
4862
  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
4863
  let manifest;
4382
4864
  try {
4383
- manifest = JSON.parse(readFileSync17(join21(cwd, "package.json"), "utf8"));
4865
+ manifest = JSON.parse(readFileSync19(join24(cwd, "package.json"), "utf8"));
4384
4866
  } catch {
4385
4867
  return [];
4386
4868
  }
@@ -4445,17 +4927,31 @@ var OxlintAdapter = class extends BaseAdapter {
4445
4927
  return presetOfVariant(presetNameFromPath(readLintAdoption(cwd).preset));
4446
4928
  }
4447
4929
  /**
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.
4930
+ * The autofix pass, and what it did.
4931
+ *
4932
+ * Output is captured rather than streamed: the fix run is noise, and the verification run
4933
+ * that follows is the one a developer reads. But captured is not the same as discarded, and
4934
+ * it used to be discarded, which is why a pass that never ran looked exactly like one that
4935
+ * had nothing to do.
4936
+ *
4937
+ * The exit status cannot be the signal, and neither can the stream. Measured on oxlint: a
4938
+ * healthy run on a module with remaining violations and a run whose `extends` path resolves
4939
+ * nowhere BOTH exit 1 and both write to stdout. One module was checked and one was not, and
4940
+ * nothing in the process result separates them, so the only signal is what the tool said.
4450
4941
  */
4451
4942
  fixPass(ctx, oxlint, env) {
4452
- const result = spawnSync5(oxlint, ["-c", LINT_CONFIG_FILE, "--fix", "."], {
4943
+ const before = snapshotFiles(ctx.cwd, LINTABLE_EXTENSIONS);
4944
+ const result = spawnSync4(oxlint, ["-c", LINT_CONFIG_FILE, "--fix", "."], {
4453
4945
  cwd: ctx.cwd,
4454
4946
  encoding: "utf8",
4455
4947
  env,
4456
4948
  maxBuffer: CAPTURE_MAX_BUFFER
4457
4949
  });
4458
- return result.status ?? 1;
4950
+ if (result.error) return { changed: [], failure: result.error.message };
4951
+ const failure = readFailure(`${result.stdout ?? ""}
4952
+ ${result.stderr ?? ""}`);
4953
+ if (failure !== void 0) return { changed: [], failure };
4954
+ return { changed: changedSince(ctx.cwd, before, LINTABLE_EXTENSIONS), failure: void 0 };
4459
4955
  }
4460
4956
  /** The module's current `lint` script, so adoption replaces only the eslint part of it. */
4461
4957
  /**
@@ -4478,7 +4974,8 @@ var OxlintAdapter = class extends BaseAdapter {
4478
4974
  const oxlint = resolveOxlint(ctx.cwd);
4479
4975
  if (!oxlint) return;
4480
4976
  const env = { ...process.env, PATH: oxlintPath(ctx.cwd, process.env) };
4481
- this.fixPass(ctx, oxlint, env);
4977
+ process.stderr.write(` ${describeFixOutcome(this.fixPass(ctx, oxlint, env), "lint")}
4978
+ `);
4482
4979
  this.writeModuleBaseline(ctx, oxlint, env);
4483
4980
  }
4484
4981
  /**
@@ -4499,20 +4996,20 @@ var OxlintAdapter = class extends BaseAdapter {
4499
4996
  * build the developer can see, rather than a silent half-adoption they cannot.
4500
4997
  */
4501
4998
  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;
4999
+ const configPath = join25(ctx.cwd, LINT_CONFIG_FILE);
5000
+ const baselinePath = join25(ctx.cwd, LINT_BASELINE_FILE);
5001
+ if (!existsSync21(configPath)) return;
4505
5002
  let config;
4506
5003
  try {
4507
5004
  config = parseJsonc(
4508
- readFileSync18(configPath, "utf8"),
5005
+ readFileSync20(configPath, "utf8"),
4509
5006
  LINT_CONFIG_FILE
4510
5007
  );
4511
5008
  } catch {
4512
5009
  return;
4513
5010
  }
4514
5011
  const current = Array.isArray(config.extends) ? config.extends : [];
4515
- const measurePath = join22(ctx.cwd, LINT_MEASURE_FILE);
5012
+ const measurePath = join25(ctx.cwd, LINT_MEASURE_FILE);
4516
5013
  let measured;
4517
5014
  try {
4518
5015
  writeFileSync2(
@@ -4520,7 +5017,7 @@ var OxlintAdapter = class extends BaseAdapter {
4520
5017
  `${JSON.stringify({ ...config, extends: extendsWithBaseline(current, false) }, null, 2)}
4521
5018
  `
4522
5019
  );
4523
- measured = spawnSync5(
5020
+ measured = spawnSync4(
4524
5021
  oxlint,
4525
5022
  [
4526
5023
  "-c",
@@ -4560,7 +5057,7 @@ var OxlintAdapter = class extends BaseAdapter {
4560
5057
  }
4561
5058
  }
4562
5059
  async run(ctx) {
4563
- if (!existsSync19(join22(ctx.cwd, LINT_CONFIG_FILE))) {
5060
+ if (!existsSync21(join25(ctx.cwd, LINT_CONFIG_FILE))) {
4564
5061
  process.stderr.write(
4565
5062
  `sentinel lint(oxlint): no ${LINT_CONFIG_FILE} in this module; run \`sentinel --init --lint\` to adopt.
4566
5063
  `
@@ -4605,9 +5102,10 @@ var OxlintAdapter = class extends BaseAdapter {
4605
5102
  valueFlags: OXLINT_VALUE_FLAGS
4606
5103
  });
4607
5104
  const targets = paths.length > 0 ? paths : ["."];
5105
+ const emptyScope = scopeFlags(paths, passedOptions);
4608
5106
  const lint = (extra) => {
4609
- const passed = [...extra, ...passedOptions];
4610
- const result = spawnSync5(oxlint, ["-c", LINT_CONFIG_FILE, ...passed, ...targets], {
5107
+ const passed = [...extra, ...emptyScope, ...passedOptions];
5108
+ const result = spawnSync4(oxlint, ["-c", LINT_CONFIG_FILE, ...passed, ...targets], {
4611
5109
  cwd: ctx.cwd,
4612
5110
  stdio: "inherit",
4613
5111
  env
@@ -4645,6 +5143,33 @@ var OxlintAdapter = class extends BaseAdapter {
4645
5143
  rule: entry.rule,
4646
5144
  reason: entry.reason
4647
5145
  })),
5146
+ // A FOURTH state, and the only one that was declared and never reported: rules oxlint
5147
+ // cannot run at all, so they can be neither enforced nor disabled. Each preset has
5148
+ // carried this list since the Svelte work, and nothing read it outside a test.
5149
+ //
5150
+ // The gap that made it matter: `no-restricted-syntax` has no oxlint equivalent, so two
5151
+ // architectural guards a Nest service relied on vanished at adoption with nothing said.
5152
+ // The adopter found it himself and rebuilt them as a local plugin. The next one might
5153
+ // not, which is the whole argument for saying it out loud.
5154
+ uncovered: uncoveredFor(presetVariant(ctx.preset, ctx.cwd)).map((entry) => ({
5155
+ rule: entry.rule,
5156
+ reason: entry.reason
5157
+ })),
5158
+ // What this module HOLDS: the rules its own baseline lowers to `warn`.
5159
+ //
5160
+ // Distinct from `downgraded` just above, which is the PRESET's doing and identical for
5161
+ // every module on it. These are this module's own debt, and leaving them out was a real
5162
+ // gap: `--inspect` could tell you the preset holds 6 rules while saying nothing about
5163
+ // the 3 your module holds on top, so nobody could see their own debt without opening a
5164
+ // file that says "do not edit by hand".
5165
+ //
5166
+ // Listed, not counted. The list is free — it is the committed file — while a count means
5167
+ // running oxlint, which this verb deliberately does not do (that is what keeps a
5168
+ // 441-module sweep at ~8s per role). `--run --lint --json` carries the per-rule counts.
5169
+ held: heldRules(ctx.cwd).map((rule) => ({
5170
+ rule,
5171
+ 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.`
5172
+ })),
4648
5173
  // What this module enforces BEYOND the preset. Not drift, and not covered by anything
4649
5174
  // else here: the stub still holds only `extends` and `ignorePatterns`, so a module with
4650
5175
  // a team layer reports `conformant=true drift=[]` and used to say nothing at all about
@@ -4680,7 +5205,7 @@ var OxlintAdapter = class extends BaseAdapter {
4680
5205
  let stub;
4681
5206
  try {
4682
5207
  stub = parseJsonc(
4683
- readFileSync18(join22(cwd, LINT_CONFIG_FILE), "utf8"),
5208
+ readFileSync20(join25(cwd, LINT_CONFIG_FILE), "utf8"),
4684
5209
  LINT_CONFIG_FILE
4685
5210
  );
4686
5211
  } catch {
@@ -4690,7 +5215,7 @@ var OxlintAdapter = class extends BaseAdapter {
4690
5215
  for (const entry of stub.extends ?? []) {
4691
5216
  try {
4692
5217
  const preset = parseJsonc(
4693
- readFileSync18(join22(cwd, entry), "utf8"),
5218
+ readFileSync20(join25(cwd, entry), "utf8"),
4694
5219
  entry
4695
5220
  );
4696
5221
  for (const rule of Object.keys(preset.rules ?? {})) names.add(rule);
@@ -4707,7 +5232,7 @@ var OxlintAdapter = class extends BaseAdapter {
4707
5232
  const oxlint = resolveOxlint(ctx.cwd);
4708
5233
  if (!oxlint) return { ok: false, code: 1, metrics: { error: "oxlint not found" } };
4709
5234
  const typeAware = canRunTypeAware(ctx.cwd) ? ["--type-aware"] : [];
4710
- const result = spawnSync5(oxlint, ["-c", LINT_CONFIG_FILE, ...typeAware, "-f", "json", "."], {
5235
+ const result = spawnSync4(oxlint, ["-c", LINT_CONFIG_FILE, ...typeAware, "-f", "json", "."], {
4711
5236
  cwd: ctx.cwd,
4712
5237
  encoding: "utf8",
4713
5238
  env: { ...process.env, PATH: oxlintPath(ctx.cwd, process.env) },
@@ -4760,14 +5285,14 @@ var OxlintAdapter = class extends BaseAdapter {
4760
5285
  let parsed;
4761
5286
  try {
4762
5287
  parsed = parseJsonc(
4763
- readFileSync18(join22(cwd, LINT_CONFIG_FILE), "utf8"),
5288
+ readFileSync20(join25(cwd, LINT_CONFIG_FILE), "utf8"),
4764
5289
  LINT_CONFIG_FILE
4765
5290
  );
4766
5291
  } catch {
4767
5292
  return void 0;
4768
5293
  }
4769
5294
  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)));
5295
+ return targets.find((target) => !existsSync21(join25(cwd, target)));
4771
5296
  }
4772
5297
  /** Announce what is not enforced, so reduced coverage is never silent. */
4773
5298
  announceDisabled(preset) {
@@ -4798,36 +5323,10 @@ function registerLint() {
4798
5323
  }
4799
5324
 
4800
5325
  // 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";
5326
+ import { spawnSync as spawnSync5 } from "child_process";
5327
+ import { existsSync as existsSync22, readFileSync as readFileSync22 } from "fs";
4803
5328
  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
- };
5329
+ import { join as join27 } from "path";
4831
5330
 
4832
5331
  // src/roles/typescript/presets/nest.json
4833
5332
  var nest_default2 = {
@@ -4878,15 +5377,41 @@ var react_default2 = {
4878
5377
  }
4879
5378
  };
4880
5379
 
5380
+ // src/roles/typescript/presets/shared.json
5381
+ var shared_default2 = {
5382
+ $schema: "https://json.schemastore.org/tsconfig",
5383
+ compilerOptions: {
5384
+ strict: true,
5385
+ noFallthroughCasesInSwitch: true,
5386
+ forceConsistentCasingInFileNames: true,
5387
+ esModuleInterop: true,
5388
+ skipLibCheck: true
5389
+ },
5390
+ deferred: {
5391
+ noImplicitAny: {
5392
+ phase: 2,
5393
+ reason: "the implicit-any migration (TS70xx)"
5394
+ },
5395
+ noUnusedLocals: {
5396
+ phase: 2,
5397
+ reason: "unused-local cleanup (TS6133)"
5398
+ },
5399
+ noUnusedParameters: {
5400
+ phase: 2,
5401
+ reason: "unused-parameter cleanup (TS6133)"
5402
+ }
5403
+ }
5404
+ };
5405
+
4881
5406
  // src/roles/typescript/preset-data.ts
4882
- var BASE = base_default3;
5407
+ var SHARED = shared_default2;
4883
5408
  var LAYERS = {
4884
5409
  nest: nest_default2,
4885
5410
  node: node_default2,
4886
5411
  react: react_default2
4887
5412
  };
4888
5413
  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));
5414
+ 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
5415
  function validate2() {
4891
5416
  for (const { rule, phase, reason } of DEFERRED_RULES) {
4892
5417
  if (!Number.isInteger(phase) || phase < 2) {
@@ -4899,9 +5424,9 @@ function validate2() {
4899
5424
  `deferred rule "${rule}" has no reason; --inspect reports it and would say nothing`
4900
5425
  );
4901
5426
  }
4902
- if (rule in BASE.compilerOptions) {
5427
+ if (rule in SHARED.compilerOptions) {
4903
5428
  throw new Error(
4904
- `"${rule}" is deferred and also set by the base preset; it would be announced as not enforced while being enforced`
5429
+ `"${rule}" is deferred and also set by the shared layer; it would be announced as not enforced while being enforced`
4905
5430
  );
4906
5431
  }
4907
5432
  for (const [preset, layer] of Object.entries(LAYERS)) {
@@ -4921,8 +5446,8 @@ function tsPresetFor(preset) {
4921
5446
  const layer = LAYERS[preset];
4922
5447
  if (!layer) throw new Error(`no TypeScript preset ships for preset "${preset}"`);
4923
5448
  return {
4924
- ...BASE.$schema === void 0 ? {} : { $schema: BASE.$schema },
4925
- compilerOptions: { ...BASE.compilerOptions, ...layer.compilerOptions }
5449
+ ...SHARED.$schema === void 0 ? {} : { $schema: SHARED.$schema },
5450
+ compilerOptions: { ...SHARED.compilerOptions, ...layer.compilerOptions }
4926
5451
  };
4927
5452
  }
4928
5453
 
@@ -4982,8 +5507,8 @@ function readTsconfigAdoption(cwd) {
4982
5507
  }
4983
5508
 
4984
5509
  // src/roles/typescript/adapters/tsc/plan.ts
4985
- import { readFileSync as readFileSync19 } from "fs";
4986
- import { join as join23 } from "path";
5510
+ import { readFileSync as readFileSync21 } from "fs";
5511
+ import { join as join26 } from "path";
4987
5512
 
4988
5513
  // src/roles/typescript/typecheck-script.ts
4989
5514
  var SENTINEL_TYPECHECK_COMMAND = "sentinel --run --typescript";
@@ -5076,7 +5601,7 @@ function planAdoption(context) {
5076
5601
  };
5077
5602
  }
5078
5603
  const existing = parseJsonc(
5079
- readFileSync19(join23(context.cwd, target.path), "utf8"),
5604
+ readFileSync21(join26(context.cwd, target.path), "utf8"),
5080
5605
  target.path
5081
5606
  );
5082
5607
  const extendsChain = composeExtends(existing.extends, preset);
@@ -5214,7 +5739,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5214
5739
  let chain;
5215
5740
  try {
5216
5741
  const parsed = parseJsonc(
5217
- readFileSync20(join24(cwd, target.path), "utf8"),
5742
+ readFileSync22(join27(cwd, target.path), "utf8"),
5218
5743
  target.path
5219
5744
  );
5220
5745
  chain = parsed.extends;
@@ -5227,7 +5752,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5227
5752
  );
5228
5753
  if (preset === void 0) return void 0;
5229
5754
  try {
5230
- createRequire4(join24(cwd, "noop.js")).resolve(preset);
5755
+ createRequire4(join27(cwd, "noop.js")).resolve(preset);
5231
5756
  return void 0;
5232
5757
  } catch {
5233
5758
  return preset;
@@ -5268,7 +5793,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5268
5793
  return { ok: false, code: 1 };
5269
5794
  }
5270
5795
  if (options.length > 0) return this.runWithOptions(ctx, tsc, config);
5271
- const result = spawnSync6(tsc, ["-b", config], { cwd: ctx.cwd, stdio: "inherit" });
5796
+ const result = spawnSync5(tsc, ["-b", config], { cwd: ctx.cwd, stdio: "inherit" });
5272
5797
  if (result.error) {
5273
5798
  process.stderr.write(
5274
5799
  `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
@@ -5303,7 +5828,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5303
5828
  let worst = 0;
5304
5829
  for (const project of this.referencedProjects(ctx.cwd, config)) {
5305
5830
  const args = ["-p", project, "--noEmit", "--composite", "false", ...ctx.toolArgs ?? []];
5306
- const result = spawnSync6(tsc, args, { cwd: ctx.cwd, stdio: "inherit" });
5831
+ const result = spawnSync5(tsc, args, { cwd: ctx.cwd, stdio: "inherit" });
5307
5832
  if (result.error) {
5308
5833
  process.stderr.write(
5309
5834
  `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
@@ -5323,7 +5848,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5323
5848
  referencedProjects(cwd, config) {
5324
5849
  try {
5325
5850
  const parsed = parseJsonc(
5326
- readFileSync20(join24(cwd, config), "utf8"),
5851
+ readFileSync22(join27(cwd, config), "utf8"),
5327
5852
  config
5328
5853
  );
5329
5854
  const referenced = (parsed.references ?? []).map((reference) => reference.path).filter((path) => typeof path === "string" && path.length > 0);
@@ -5339,7 +5864,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5339
5864
  * check.
5340
5865
  */
5341
5866
  typecheckTarget(cwd) {
5342
- if (existsSync20(join24(cwd, "tsconfig.json"))) return "tsconfig.json";
5867
+ if (existsSync22(join27(cwd, "tsconfig.json"))) return "tsconfig.json";
5343
5868
  const target = resolveTsconfigTarget(cwd);
5344
5869
  return target.reason === "none" ? null : target.path;
5345
5870
  }
@@ -5392,7 +5917,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5392
5917
  return { ok: true, code: 0, metrics: _TscAdapter.NOTHING_TO_REPORT };
5393
5918
  }
5394
5919
  const tsc = resolveBin(ctx.cwd, "tsc") ?? "tsc";
5395
- const result = spawnSync6(tsc, ["-b", config], { cwd: ctx.cwd, encoding: "utf8" });
5920
+ const result = spawnSync5(tsc, ["-b", config], { cwd: ctx.cwd, encoding: "utf8" });
5396
5921
  if (result.error) {
5397
5922
  process.stderr.write(
5398
5923
  `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?
@@ -5435,7 +5960,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5435
5960
  * errors in the output mean the rule must be on.
5436
5961
  */
5437
5962
  noImplicitAnyEnabled(cwd, tsc, config, mainOutput) {
5438
- const shown = spawnSync6(tsc, ["-p", config, "--showConfig"], { cwd, encoding: "utf8" });
5963
+ const shown = spawnSync5(tsc, ["-p", config, "--showConfig"], { cwd, encoding: "utf8" });
5439
5964
  if (shown.status === 0 && shown.stdout) {
5440
5965
  try {
5441
5966
  const co = parseJsonc(
@@ -5487,7 +6012,7 @@ function detectFramework(packageJson) {
5487
6012
  }
5488
6013
 
5489
6014
  // src/core/dispatch.ts
5490
- import { basename as basename2 } from "path";
6015
+ import { basename as basename3 } from "path";
5491
6016
 
5492
6017
  // src/shared/text.ts
5493
6018
  function ensureLines(current, lines) {
@@ -5543,9 +6068,16 @@ function replaceLines(current, replacements) {
5543
6068
  }
5544
6069
 
5545
6070
  // src/core/apply-plan.ts
5546
- import { existsSync as existsSync21, readFileSync as readFileSync21, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
6071
+ import { existsSync as existsSync23, readFileSync as readFileSync23, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
5547
6072
  import { resolve as resolve4, sep } from "path";
5548
6073
  import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
6074
+
6075
+ // src/shared/deep-merge.ts
6076
+ function isPlainObject(value) {
6077
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6078
+ }
6079
+
6080
+ // src/core/apply-plan.ts
5549
6081
  function resolveWithinRoot(cwd, relativePath) {
5550
6082
  const root = resolve4(cwd);
5551
6083
  const absolutePath = resolve4(root, relativePath);
@@ -5555,7 +6087,7 @@ function resolveWithinRoot(cwd, relativePath) {
5555
6087
  return absolutePath;
5556
6088
  }
5557
6089
  function readIfExists(absolutePath) {
5558
- return existsSync21(absolutePath) ? readFileSync21(absolutePath, "utf8") : void 0;
6090
+ return existsSync23(absolutePath) ? readFileSync23(absolutePath, "utf8") : void 0;
5559
6091
  }
5560
6092
  function* leaves(value, prefix = []) {
5561
6093
  for (const [key, keyValue] of Object.entries(value)) {
@@ -5673,8 +6205,8 @@ function applyPlan(cwd, plan3) {
5673
6205
  }
5674
6206
 
5675
6207
  // 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";
6208
+ import { existsSync as existsSync24, readdirSync as readdirSync3, readFileSync as readFileSync24 } from "fs";
6209
+ import { join as join28 } from "path";
5678
6210
  var PATH_SIGNALS = [
5679
6211
  {
5680
6212
  preset: "nest",
@@ -5688,11 +6220,11 @@ var DEPENDENCY_SIGNALS = [
5688
6220
  { preset: "nest", pattern: /^@nestjs\// }
5689
6221
  ];
5690
6222
  function dependencyNames(cwd) {
5691
- const path = join25(cwd, "package.json");
5692
- if (!existsSync22(path)) return [];
6223
+ const path = join28(cwd, "package.json");
6224
+ if (!existsSync24(path)) return [];
5693
6225
  try {
5694
6226
  const manifest = parseJsonc(
5695
- readFileSync22(path, "utf8"),
6227
+ readFileSync24(path, "utf8"),
5696
6228
  path
5697
6229
  );
5698
6230
  return [
@@ -5715,7 +6247,7 @@ function declaresJsx(cwd) {
5715
6247
  for (const name of entries) {
5716
6248
  try {
5717
6249
  const config = parseJsonc(
5718
- readFileSync22(join25(cwd, name), "utf8"),
6250
+ readFileSync24(join28(cwd, name), "utf8"),
5719
6251
  name
5720
6252
  );
5721
6253
  if (config.compilerOptions?.jsx !== void 0) return true;
@@ -5812,13 +6344,26 @@ async function dispatch(opts) {
5812
6344
  const resolutionPreset = opts.preset ?? declaredPresetFor(opts.target, opts.cwd) ?? detected;
5813
6345
  const adapter = resolve(opts.target, resolutionPreset, opts.runner);
5814
6346
  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}
6347
+ if (opts.preset !== void 0) {
6348
+ const contradiction = presetContradiction(opts.preset, opts.cwd);
6349
+ if (contradiction !== void 0) {
6350
+ process.stderr.write(`sentinel (${opts.target}): ${contradiction}
5818
6351
  `);
5819
- return 1;
6352
+ return 1;
6353
+ }
6354
+ }
6355
+ let effective = preset;
6356
+ if (opts.preset === void 0 && adapter.declaredPreset?.(opts.cwd) === void 0) {
6357
+ const [signal] = presetSignals(opts.cwd);
6358
+ if (signal !== void 0 && signal.preset !== preset) {
6359
+ effective = signal.preset;
6360
+ process.stderr.write(
6361
+ ` detected "${preset}" from dependencies, but ${signal.evidence}, so adopting as "${signal.preset}". Pass --preset to override.
6362
+ `
6363
+ );
6364
+ }
5820
6365
  }
5821
- const context = { cwd: opts.cwd, preset };
6366
+ const context = { cwd: opts.cwd, preset: effective };
5822
6367
  const plan3 = await adapter.plan(context);
5823
6368
  if (plan3.blocked) {
5824
6369
  process.stderr.write(`sentinel (${opts.target}): ${plan3.blocked}
@@ -5843,7 +6388,7 @@ async function dispatch(opts) {
5843
6388
  process.stderr.write(` fixing what ${opts.target} can fix automatically...
5844
6389
  `);
5845
6390
  await adapter.afterInit({
5846
- module: basename2(opts.cwd),
6391
+ module: basename3(opts.cwd),
5847
6392
  cwd: opts.cwd,
5848
6393
  preset,
5849
6394
  ci: false,
@@ -5865,19 +6410,21 @@ export {
5865
6410
  resolveBin,
5866
6411
  readOwnVersion,
5867
6412
  readProjectPackageJson,
5868
- readNxProjectName,
6413
+ moduleName,
6414
+ buildConfigFile,
6415
+ VERBS,
6416
+ TARGETS,
6417
+ LONG_RUNNING_TARGETS,
6418
+ SWEEPABLE_TARGETS,
6419
+ PRESET_NAMES,
5869
6420
  WORKSPACE_ROOT_MARKER,
5870
6421
  findWorkspaceRoot,
5871
6422
  ensureWorkspacePrep,
5872
6423
  inspectWorkspacePrep,
5873
6424
  palette,
5874
- VERBS,
5875
- TARGETS,
5876
- SWEEPABLE_TARGETS,
5877
- PRESET_NAMES,
5878
6425
  registerAdapters,
5879
6426
  describeFramework,
5880
6427
  detectFramework,
5881
6428
  dispatch
5882
6429
  };
5883
- //# sourceMappingURL=chunk-TWL6T237.js.map
6430
+ //# sourceMappingURL=chunk-7WB7MQHD.js.map