@hublo/sentinel 1.1.0-alpha.5 → 1.1.0-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -158,11 +158,11 @@ pnpm dlx @hublo/sentinel@<exact-version> --inspect --typescript --module <name>
158
158
 
159
159
  Every check is described by three layers:
160
160
 
161
- | Layer | Flag | What it is | Examples |
162
- | -------------------- | --------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------- |
163
- | **target** (role) | `--lint`, `--typescript`, … | the _kind_ of check, stable | `lint` `format` `typescript` `build` `test` `static-analysis` `runtime-analysis` |
164
- | **runner** (adapter) | `--runner=<tool>` | the _tool_ behind the target, swappable | lint: `eslint`/`biome`/`oxlint` · types: `tsc`/`tsgo` · build: `vite` · test: `vitest` |
165
- | **preset** (preset) | detected / `--preset` | the _variant_ per stack (strict by default) | `react` `nest` `node` `svelte` (svelte: lint only, no tsconfig preset yet) |
161
+ | Layer | Flag | What it is | Examples |
162
+ | -------------------- | --------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
163
+ | **target** (role) | `--lint`, `--typescript`, … | the _kind_ of check, stable | `lint` `format` `typescript` `build` `test` `static-analysis` `runtime-analysis` |
164
+ | **runner** (adapter) | `--runner=<tool>` | the _tool_ behind the target, swappable | lint: `eslint`/`biome`/`oxlint` · types: `tsc`/`tsgo` · build: `vite` · test: `vitest` |
165
+ | **preset** (preset) | detected / `--preset` | the _variant_ per stack (strict by default) | `react` `nest` `node` `svelte` (svelte: lint + format; no tsconfig preset yet, so the TypeScript role stands aside on those modules and the other two adopt) |
166
166
 
167
167
  A run is `target × runner × preset`, e.g. `sentinel --run --lint --runner=eslint` from the `host-admin` dir.
168
168
 
@@ -16,7 +16,7 @@ import {
16
16
  registerAdapters,
17
17
  resolve,
18
18
  resolveBin
19
- } from "../chunk-NAUACDXT.js";
19
+ } from "../chunk-FA6N4XCP.js";
20
20
 
21
21
  // bin/sentinel.ts
22
22
  import { program } from "commander";
@@ -223,6 +223,14 @@ async function analyse(params) {
223
223
  toolArgs: params.toolArgs
224
224
  };
225
225
  try {
226
+ let adoption;
227
+ if (params.verb === "run" || params.verb === "report") {
228
+ try {
229
+ adoption = await adapter.status(ctx);
230
+ } catch {
231
+ }
232
+ }
233
+ const adoptionFields = adoption ? { adopted: adoption.adopted, preset: adoption.preset, conformant: adoption.conformant } : {};
226
234
  if (params.verb === "run") {
227
235
  if (params.modules.length > 1) {
228
236
  const err = palette(process.stderr);
@@ -239,7 +247,7 @@ async function analyse(params) {
239
247
  flavour: effectiveFlavour,
240
248
  ok: result.ok,
241
249
  status: result.ok ? "ok" : "failed",
242
- data: {}
250
+ data: { ...adoptionFields }
243
251
  });
244
252
  worstCode = Math.max(worstCode, result.code);
245
253
  } else if (params.verb === "report") {
@@ -250,12 +258,15 @@ async function analyse(params) {
250
258
  flavour: effectiveFlavour,
251
259
  ok: result.ok,
252
260
  status: result.ok ? "ok" : "failed",
253
- data: result.metrics ?? {}
261
+ // Engine fields LAST, so they WIN. Spreading them first let a role's own metrics
262
+ // override them, which is the disagreement this was written to remove: the point
263
+ // is that adoption has exactly one source, not that it has a default.
264
+ data: { ...result.metrics ?? {}, ...adoptionFields }
254
265
  });
255
266
  worstCode = Math.max(worstCode, result.code);
256
267
  } else if (params.verb === "inspect") {
257
- const [config, adoption] = await Promise.all([adapter.inspect(ctx), adapter.status(ctx)]);
258
- const ok = !adoption.unreadable && (!adoption.adopted || adoption.conformant);
268
+ const [config, adoption2] = await Promise.all([adapter.inspect(ctx), adapter.status(ctx)]);
269
+ const ok = !adoption2.unreadable && (!adoption2.adopted || adoption2.conformant);
259
270
  results.push({
260
271
  project: module.name,
261
272
  target,
@@ -265,7 +276,7 @@ async function analyse(params) {
265
276
  // `inspect` is typed as `unknown` (each role shapes its own detail), so the
266
277
  // merge is guarded rather than assumed: a role returning a non-object still
267
278
  // gets its adoption fields instead of throwing here.
268
- data: config && typeof config === "object" ? { ...config, ...adoption } : { ...adoption }
279
+ data: config && typeof config === "object" ? { ...config, ...adoption2 } : { ...adoption2 }
269
280
  });
270
281
  if (!ok) worstCode = Math.max(worstCode, 1);
271
282
  } else {
@@ -140,8 +140,8 @@ var PRESET_NAMES = ["react", "nest", "svelte", "node", "tools"];
140
140
 
141
141
  // src/roles/format/adapters/oxfmt/oxfmt.adapter.ts
142
142
  import { spawnSync } from "child_process";
143
- import { existsSync as existsSync10, readFileSync as readFileSync7 } from "fs";
144
- import { join as join10 } from "path";
143
+ import { existsSync as existsSync10, readFileSync as readFileSync8 } from "fs";
144
+ import { join as join11 } from "path";
145
145
 
146
146
  // src/core/config/has-source.ts
147
147
  import { readdirSync } from "fs";
@@ -548,6 +548,12 @@ function invokesFormat(script) {
548
548
  function keepsOtherCommands2(script, command) {
549
549
  return keepsOtherCommands(script, command ?? SENTINEL_FORMAT_COMMAND);
550
550
  }
551
+ function writesWhenRewritten(name, command) {
552
+ if (/[:-](fix|write)$/.test(name)) return true;
553
+ if (CHECK_SCRIPT_NAMES.has(name) || /[:-]check$/.test(name)) return false;
554
+ return /--write\b/.test(command);
555
+ }
556
+ var CHECK_SCRIPT_NAMES = /* @__PURE__ */ new Set(["lint", "format", "check", "test", "typecheck", "verify"]);
551
557
 
552
558
  // src/roles/format/inherited-ignores.ts
553
559
  import { existsSync as existsSync7, readFileSync as readFileSync4 } from "fs";
@@ -578,6 +584,42 @@ function inheritedIgnorePatterns(workspaceRoot) {
578
584
  return [...new Set(carried)].sort();
579
585
  }
580
586
 
587
+ // src/roles/format/oxfmt-diagnostics.ts
588
+ var MESSAGE = /^\s*[x\u00d7]\s+(.+?)\s*$/;
589
+ var LOCATION = /[,\u256d][-\u2500]\[([^\]]+?):(\d+):\d+\]/;
590
+ var ANSI = /\u001b\[[0-9;]*m/g;
591
+ function plain(output) {
592
+ return output.replace(ANSI, "");
593
+ }
594
+ function unparseableFiles(output) {
595
+ const diagnostics = [];
596
+ let pending;
597
+ for (const raw of plain(output).split("\n")) {
598
+ const location = LOCATION.exec(raw);
599
+ if (location && pending) {
600
+ diagnostics.push({
601
+ file: location[1] ?? "",
602
+ line: Number(location[2] ?? 0),
603
+ message: pending
604
+ });
605
+ pending = void 0;
606
+ continue;
607
+ }
608
+ const message = MESSAGE.exec(raw);
609
+ if (message) pending = message[1];
610
+ }
611
+ return diagnostics;
612
+ }
613
+ function oxfmtSaid(output) {
614
+ const lines = plain(output).split("\n");
615
+ const diagnostic = lines.find((line) => MESSAGE.test(line));
616
+ if (diagnostic) return MESSAGE.exec(diagnostic)?.[1] ?? diagnostic.trim();
617
+ return [...lines].reverse().find((line) => line.trim().length > 0)?.trim() ?? "no output";
618
+ }
619
+ function needsSvelteCompiler(output) {
620
+ return /svelte\/compiler/.test(output);
621
+ }
622
+
581
623
  // src/roles/format/presets/base.json
582
624
  var base_default = {
583
625
  printWidth: 80,
@@ -594,17 +636,21 @@ var base_default = {
594
636
  };
595
637
 
596
638
  // src/roles/format/preset-data.ts
597
- var FORMAT_PRESETS = ["base"];
639
+ var FORMAT_PRESETS = ["base", "svelte"];
598
640
  function hasFormatPreset(name) {
599
641
  return FORMAT_PRESETS.includes(name);
600
642
  }
601
- function formatPresetFor(_preset) {
602
- return base_default;
643
+ function formatPresetName(declared) {
644
+ return declared === "svelte" ? "svelte" : "base";
645
+ }
646
+ function formatPresetFor(preset) {
647
+ const base = base_default;
648
+ return formatPresetName(preset) === "svelte" ? { ...base, svelte: true } : base;
603
649
  }
604
650
 
605
651
  // src/roles/format/prettier-config.ts
606
- import { existsSync as existsSync8, readFileSync as readFileSync5 } from "fs";
607
- import { join as join8 } from "path";
652
+ import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
653
+ import { join as join9 } from "path";
608
654
 
609
655
  // src/shared/jsonc.ts
610
656
  import { parse, printParseErrorCode } from "jsonc-parser";
@@ -618,17 +664,78 @@ function parseJsonc(text, source = "config") {
618
664
  return value;
619
665
  }
620
666
 
667
+ // src/roles/format/resolve-oxfmt.ts
668
+ import { readFileSync as readFileSync5 } from "fs";
669
+ import { createRequire as createRequire2 } from "module";
670
+ import { dirname as dirname4, join as join8 } from "path";
671
+ function resolveOxfmt(cwd) {
672
+ return resolveBin(cwd, "oxfmt") ?? binFromOwnInstall("oxfmt", "oxfmt");
673
+ }
674
+ function oxfmtRejects(key, value) {
675
+ const schema = configSchema();
676
+ const property = schema?.properties?.[key];
677
+ if (!property) return false;
678
+ const definition = resolveRef(schema, property);
679
+ if (Array.isArray(definition.enum)) return !definition.enum.includes(value);
680
+ if (definition.type === "boolean") return typeof value !== "boolean";
681
+ if (definition.type === "integer" || definition.type === "number")
682
+ return typeof value !== "number";
683
+ return false;
684
+ }
685
+ function resolveRef(schema, node) {
686
+ const ref = node.$ref ?? node.allOf?.[0]?.$ref;
687
+ const name = ref?.startsWith("#/definitions/") ? ref.slice("#/definitions/".length) : void 0;
688
+ return (name ? schema.definitions?.[name] : void 0) ?? node;
689
+ }
690
+ var cachedSchema;
691
+ function configSchema() {
692
+ if (cachedSchema !== void 0) return cachedSchema ?? void 0;
693
+ cachedSchema = readConfigSchema() ?? null;
694
+ return cachedSchema ?? void 0;
695
+ }
696
+ function readConfigSchema() {
697
+ try {
698
+ const manifest = createRequire2(import.meta.url).resolve("oxfmt/package.json");
699
+ const schemaPath = join8(dirname4(manifest), "configuration_schema.json");
700
+ return JSON.parse(readFileSync5(schemaPath, "utf8"));
701
+ } catch {
702
+ return void 0;
703
+ }
704
+ }
705
+
621
706
  // src/roles/format/prettier-config.ts
622
707
  var UNSUPPORTED = /* @__PURE__ */ new Set(["plugins", "parser", "filepath", "rangeStart", "rangeEnd"]);
708
+ function toOxfmtOverrides(value) {
709
+ if (!Array.isArray(value)) return { overrides: [], unresolved: [] };
710
+ const overrides = [];
711
+ const unresolved = [];
712
+ for (const entry of value) {
713
+ if (typeof entry !== "object" || entry === null) continue;
714
+ const { files, options } = entry;
715
+ const patterns = typeof files === "string" ? [files] : Array.isArray(files) ? files : [];
716
+ if (patterns.length === 0) continue;
717
+ const kept = {};
718
+ for (const [key, option] of Object.entries(options ?? {})) {
719
+ const where = `overrides[${patterns.join(", ")}].${key}`;
720
+ if (UNSUPPORTED.has(key)) unresolved.push(where);
721
+ else if (!PERMITTED_LOCAL_KEYS.includes(key)) continue;
722
+ else if (oxfmtRejects(key, option)) {
723
+ unresolved.push(`${where}: ${JSON.stringify(option)} (oxfmt does not accept that value)`);
724
+ } else kept[key] = option;
725
+ }
726
+ if (Object.keys(kept).length > 0) overrides.push({ files: patterns, options: kept });
727
+ }
728
+ return { overrides, unresolved };
729
+ }
623
730
  function readPrettierSettings(cwd) {
624
- const file = PRETTIER_CONFIG_FILES.find((name) => existsSync8(join8(cwd, name)));
731
+ const file = PRETTIER_CONFIG_FILES.find((name) => existsSync8(join9(cwd, name)));
625
732
  if (!file) return { options: {}, unresolved: [] };
626
733
  if (/\.(js|cjs|mjs)$/.test(file)) {
627
734
  return { options: {}, file, unresolved: [`${file} is JavaScript, so it was not executed`] };
628
735
  }
629
736
  let parsed;
630
737
  try {
631
- parsed = parseJsonc(readFileSync5(join8(cwd, file), "utf8"), file);
738
+ parsed = parseJsonc(readFileSync6(join9(cwd, file), "utf8"), file);
632
739
  } catch {
633
740
  return { options: {}, file, unresolved: [`${file} could not be parsed`] };
634
741
  }
@@ -636,10 +743,21 @@ function readPrettierSettings(cwd) {
636
743
  const unresolved = [];
637
744
  for (const [key, value] of Object.entries(parsed)) {
638
745
  if (UNSUPPORTED.has(key)) {
639
- unresolved.push(`${key} (oxfmt has no equivalent)`);
746
+ const named = key === "plugins" && Array.isArray(value) ? value.map((plugin) => `plugins: ${String(plugin)}`) : [key];
747
+ unresolved.push(...named.map((entry) => `${entry} (oxfmt has no equivalent)`));
748
+ continue;
749
+ }
750
+ if (key === "overrides") {
751
+ const translated = toOxfmtOverrides(value);
752
+ if (translated.overrides.length > 0) options.overrides = translated.overrides;
753
+ unresolved.push(...translated.unresolved.map((entry) => `${entry} (oxfmt has no equivalent)`));
640
754
  continue;
641
755
  }
642
756
  if (PERMITTED_LOCAL_KEYS.includes(key) || ALWAYS_LOCAL_KEYS.includes(key)) {
757
+ if (oxfmtRejects(key, value)) {
758
+ unresolved.push(`${key}: ${JSON.stringify(value)} (oxfmt does not accept that value)`);
759
+ continue;
760
+ }
643
761
  options[key] = value;
644
762
  }
645
763
  }
@@ -647,8 +765,8 @@ function readPrettierSettings(cwd) {
647
765
  }
648
766
 
649
767
  // src/roles/format/read-adoption.ts
650
- import { existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
651
- import { join as join9 } from "path";
768
+ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
769
+ import { join as join10 } from "path";
652
770
  var NOT_ADOPTED = (configFile, unreadable = null) => ({
653
771
  configFile,
654
772
  preset: null,
@@ -663,11 +781,11 @@ function sameValue(a, b) {
663
781
  return JSON.stringify(a) === JSON.stringify(b);
664
782
  }
665
783
  function readFormatAdoption(cwd) {
666
- const path = join9(cwd, FORMAT_CONFIG_FILE);
784
+ const path = join10(cwd, FORMAT_CONFIG_FILE);
667
785
  if (!existsSync9(path)) return NOT_ADOPTED(null);
668
786
  let parsed;
669
787
  try {
670
- parsed = parseJsonc(readFileSync6(path, "utf8"), FORMAT_CONFIG_FILE);
788
+ parsed = parseJsonc(readFileSync7(path, "utf8"), FORMAT_CONFIG_FILE);
671
789
  } catch (error) {
672
790
  const reason = error instanceof Error ? error.message : String(error);
673
791
  return NOT_ADOPTED(FORMAT_CONFIG_FILE, `${FORMAT_CONFIG_FILE} could not be parsed (${reason})`);
@@ -709,13 +827,8 @@ function readFormatAdoption(cwd) {
709
827
  };
710
828
  }
711
829
 
712
- // src/roles/format/resolve-oxfmt.ts
713
- function resolveOxfmt(cwd) {
714
- return resolveBin(cwd, "oxfmt") ?? binFromOwnInstall("oxfmt", "oxfmt");
715
- }
716
-
717
830
  // src/roles/format/adapters/oxfmt/oxfmt.adapter.ts
718
- var PRESET = "base";
831
+ var DEFAULT_PRESET = "base";
719
832
  var OXFMT_VALUE_FLAGS = ["-c", "--config", "--ignore-path", "--threads", "--stdin-filepath"];
720
833
  var OxfmtAdapter = class extends BaseAdapter {
721
834
  target = "format";
@@ -732,7 +845,8 @@ var OxfmtAdapter = class extends BaseAdapter {
732
845
  };
733
846
  }
734
847
  const own = readOwnPackage();
735
- const preset = formatPresetFor(PRESET);
848
+ const presetName = formatPresetName(context.preset);
849
+ const preset = formatPresetFor(presetName);
736
850
  const existing = readFormatAdoption(context.cwd);
737
851
  const prettier = readPrettierSettings(context.cwd);
738
852
  const local = existing.adopted ? { ...existing.local } : { ...prettier.options };
@@ -748,7 +862,7 @@ var OxfmtAdapter = class extends BaseAdapter {
748
862
  const unchanged = existing.adopted && existing.conformant;
749
863
  const version = unchanged && existing.presetVersion ? existing.presetVersion : own.version;
750
864
  const config = {
751
- [PROVENANCE_KEY]: { preset: PRESET, version, local: localKeys },
865
+ [PROVENANCE_KEY]: { preset: presetName, version, local: localKeys },
752
866
  ...preset,
753
867
  ...Object.fromEntries(
754
868
  [...localKeys, ...ALWAYS_LOCAL_KEYS].flatMap(
@@ -763,7 +877,7 @@ var OxfmtAdapter = class extends BaseAdapter {
763
877
  manifestOperation(context.cwd, this.formatScripts(context.cwd))
764
878
  ];
765
879
  const prettierConfigs = PRETTIER_CONFIG_FILES.filter(
766
- (name) => existsSync10(join10(context.cwd, name))
880
+ (name) => existsSync10(join11(context.cwd, name))
767
881
  );
768
882
  for (const name of prettierConfigs) operations.push({ kind: "delete", path: name });
769
883
  const removableDeps = this.modulePrettierDependencies(context.cwd);
@@ -772,7 +886,7 @@ var OxfmtAdapter = class extends BaseAdapter {
772
886
  }
773
887
  operations.push(...nxTargetOperations({ cwd: context.cwd, targets: formatTargets() }));
774
888
  const notes = [
775
- `materialized the ${PRESET} format preset into ${FORMAT_CONFIG_FILE} (${own.name}@${own.version}). oxfmt has no \`extends\`, so the values live here; \`sentinel --status --format\` reports modules left behind by a preset change`
889
+ `materialized the ${presetName} format preset into ${FORMAT_CONFIG_FILE} (${own.name}@${own.version}). oxfmt has no \`extends\`, so the values live here; \`sentinel --status --format\` reports modules left behind by a preset change`
776
890
  ];
777
891
  if (localKeys.length > 0) {
778
892
  notes.push(
@@ -784,9 +898,12 @@ var OxfmtAdapter = class extends BaseAdapter {
784
898
  `carried ${inherited.length} ignore pattern(s) from the workspace .prettierignore (${inherited.join(", ")}). oxfmt reads that file from the CURRENT directory and sentinel runs it in this module, so without them adoption would start formatting files the repo has always excluded`
785
899
  );
786
900
  }
787
- if (prettier.unresolved.length > 0) {
901
+ const lost = prettier.unresolved.filter(
902
+ (entry) => !(presetName === "svelte" && /svelte/i.test(entry))
903
+ );
904
+ if (lost.length > 0) {
788
905
  notes.push(
789
- `COULD NOT carry ${prettier.unresolved.join("; ")}. Check it and add what you need to ${FORMAT_CONFIG_FILE}, declaring the key in \`${PROVENANCE_KEY}.local\` so a re-init keeps it`
906
+ `COULD NOT carry ${lost.join("; ")}. Check it and add what you need to ${FORMAT_CONFIG_FILE}, declaring the key in \`${PROVENANCE_KEY}.local\` so a re-init keeps it`
790
907
  );
791
908
  }
792
909
  if (prettierConfigs.length > 0) {
@@ -819,7 +936,45 @@ var OxfmtAdapter = class extends BaseAdapter {
819
936
  async afterInit(ctx) {
820
937
  const oxfmt = resolveOxfmt(ctx.cwd);
821
938
  if (!oxfmt) return;
822
- spawnSync(oxfmt, ["--write", "."], { cwd: ctx.cwd, encoding: "utf8" });
939
+ const pass = spawnSync(oxfmt, ["--write", "."], { cwd: ctx.cwd, encoding: "utf8" });
940
+ if (pass.status === 0) return;
941
+ const output = `${pass.stdout ?? ""}${pass.stderr ?? ""}`;
942
+ const unparseable = unparseableFiles(output);
943
+ const warn = palette(process.stderr).warn;
944
+ if (unparseable.length > 0) {
945
+ process.stderr.write(
946
+ warn(
947
+ ` ${unparseable.length} file(s) oxfmt cannot parse, so they were NOT formatted and \`pnpm run ${FORMAT_SCRIPT_NAME}\` fails until they are fixed:
948
+ `
949
+ )
950
+ );
951
+ for (const { file, line, message } of unparseable) {
952
+ process.stderr.write(warn(` ${file}:${line} ${message}
953
+ `));
954
+ }
955
+ process.stderr.write(
956
+ palette(process.stderr).dim(
957
+ ` These are syntax errors Prettier recovered from and oxfmt refuses. They are the module's, not the migration's: fix them (usually one line), or exclude them in \`ignorePatterns\`.
958
+ `
959
+ )
960
+ );
961
+ return;
962
+ }
963
+ if (needsSvelteCompiler(output)) {
964
+ process.stderr.write(
965
+ warn(
966
+ ` .svelte files are not formatted yet: oxfmt loads the Svelte compiler from this module, and it is not installed here. Run \`pnpm install\`, then \`pnpm run ${FORMAT_SCRIPT_NAME}:fix\`.
967
+ `
968
+ )
969
+ );
970
+ return;
971
+ }
972
+ process.stderr.write(
973
+ warn(
974
+ ` the format pass did not finish, so this module is not formatted yet: ${oxfmtSaid(output)}
975
+ `
976
+ )
977
+ );
823
978
  }
824
979
  /** Check formatting; `--fix` writes. */
825
980
  async run(ctx) {
@@ -870,7 +1025,7 @@ var OxfmtAdapter = class extends BaseAdapter {
870
1025
  */
871
1026
  async inspect(ctx) {
872
1027
  const adoption = readFormatAdoption(ctx.cwd);
873
- const preset = formatPresetFor(adoption.preset ?? PRESET);
1028
+ const preset = formatPresetFor(adoption.preset ?? DEFAULT_PRESET);
874
1029
  const options = { ...preset, ...adoption.local };
875
1030
  const describe = (value) => typeof value === "string" ? value : JSON.stringify(value);
876
1031
  return {
@@ -881,7 +1036,7 @@ var OxfmtAdapter = class extends BaseAdapter {
881
1036
  options,
882
1037
  overrides: Object.entries(adoption.local).filter(([key]) => !ALWAYS_LOCAL_KEYS.includes(key)).map(([key, value]) => ({
883
1038
  rule: key,
884
- reason: key in preset ? `${describe(value)}, where the ${adoption.preset ?? PRESET} preset says ${describe(preset[key])}` : `${describe(value)}, which the preset does not set`
1039
+ reason: key in preset ? `${describe(value)}, where the ${adoption.preset ?? DEFAULT_PRESET} preset says ${describe(preset[key])}` : `${describe(value)}, which the preset does not set`
885
1040
  })),
886
1041
  // Which files this module does not format at all. A long list here is worth a look:
887
1042
  // it is the quiet way a module stops being covered.
@@ -902,7 +1057,7 @@ var OxfmtAdapter = class extends BaseAdapter {
902
1057
  */
903
1058
  async report(ctx) {
904
1059
  const adoption = readFormatAdoption(ctx.cwd);
905
- const base = { adopted: adoption.adopted, preset: adoption.preset };
1060
+ const base = {};
906
1061
  if (!adoption.adopted) return { ok: true, code: 0, metrics: { ...base, unformatted: 0 } };
907
1062
  const oxfmt = resolveOxfmt(ctx.cwd);
908
1063
  if (!oxfmt) {
@@ -948,7 +1103,7 @@ var OxfmtAdapter = class extends BaseAdapter {
948
1103
  modulePrettierDependencies(cwd) {
949
1104
  const isPrettierPackage = (name) => name === "prettier" || name.startsWith("prettier-plugin-") || name.startsWith("@prettier/");
950
1105
  try {
951
- const manifest = JSON.parse(readFileSync7(join10(cwd, "package.json"), "utf8"));
1106
+ const manifest = JSON.parse(readFileSync8(join11(cwd, "package.json"), "utf8"));
952
1107
  return Object.keys(manifest.devDependencies ?? {}).filter(isPrettierPackage).map((name) => ["devDependencies", name]);
953
1108
  } catch {
954
1109
  return [];
@@ -981,7 +1136,7 @@ var OxfmtAdapter = class extends BaseAdapter {
981
1136
  // script too and rewrote it into a second format run.
982
1137
  ownCommand !== void 0 && /\bsentinel\b/.test(command) && /--format\b/.test(command);
983
1138
  if (!needsRewrite) continue;
984
- rewritten[name] = compose(command, /--write\b/.test(command) || /:(fix|write)$/.test(name));
1139
+ rewritten[name] = compose(command, writesWhenRewritten(name, command));
985
1140
  }
986
1141
  return this.joinFormatIntoLint(scripts, rewritten, ownCommand);
987
1142
  }
@@ -1028,8 +1183,8 @@ function registerFormat() {
1028
1183
 
1029
1184
  // src/roles/lint/adapters/oxlint/oxlint.adapter.ts
1030
1185
  import { spawnSync as spawnSync2 } from "child_process";
1031
- import { existsSync as existsSync16, readFileSync as readFileSync13 } from "fs";
1032
- import { join as join17 } from "path";
1186
+ import { existsSync as existsSync16, readFileSync as readFileSync14 } from "fs";
1187
+ import { join as join18 } from "path";
1033
1188
 
1034
1189
  // src/core/config/deferred-rules.ts
1035
1190
  function deferredRuleNames(rules) {
@@ -2864,22 +3019,28 @@ function parseOxlintDiagnostics(stdout) {
2864
3019
  }
2865
3020
 
2866
3021
  // src/core/config/read-adoption.ts
2867
- import { existsSync as existsSync12, readFileSync as readFileSync9 } from "fs";
2868
- import { join as join12 } from "path";
3022
+ import { existsSync as existsSync12, readFileSync as readFileSync10 } from "fs";
3023
+ import { join as join13 } from "path";
2869
3024
 
2870
3025
  // src/core/config/owned-keys.ts
2871
- function presetOwnedKeys(config, permitted) {
3026
+ function presetOwnedKeys(config, permitted, presetSets) {
3027
+ if (!config) return [];
3028
+ return Object.keys(config).filter(
3029
+ (key) => !permitted.includes(key) && (presetSets === void 0 || presetSets.includes(key))
3030
+ );
3031
+ }
3032
+ function localOnlyKeys(config, permitted, presetSets) {
2872
3033
  if (!config) return [];
2873
- return Object.keys(config).filter((key) => !permitted.includes(key));
3034
+ return Object.keys(config).filter((key) => !permitted.includes(key) && !presetSets.includes(key));
2874
3035
  }
2875
3036
 
2876
3037
  // src/core/config/resolve-config-target.ts
2877
- import { existsSync as existsSync11, readFileSync as readFileSync8 } from "fs";
2878
- import { join as join11 } from "path";
3038
+ import { existsSync as existsSync11, readFileSync as readFileSync9 } from "fs";
3039
+ import { join as join12 } from "path";
2879
3040
  function readExtends(absolutePath) {
2880
3041
  let parsed;
2881
3042
  try {
2882
- parsed = parseJsonc(readFileSync8(absolutePath, "utf8"), absolutePath);
3043
+ parsed = parseJsonc(readFileSync9(absolutePath, "utf8"), absolutePath);
2883
3044
  } catch {
2884
3045
  return [];
2885
3046
  }
@@ -2893,7 +3054,7 @@ function resolveConfigTarget(moduleDir, { candidates, markers, fallback }) {
2893
3054
  let existing;
2894
3055
  let existingExtendsSomething = false;
2895
3056
  for (const candidate of candidates) {
2896
- const absolutePath = join11(moduleDir, candidate);
3057
+ const absolutePath = join12(moduleDir, candidate);
2897
3058
  if (!existsSync11(absolutePath)) continue;
2898
3059
  const chain = readExtends(absolutePath);
2899
3060
  if (existing === void 0) {
@@ -2927,12 +3088,12 @@ var NOT_ADOPTED2 = (configFile, unreadable = null) => ({
2927
3088
  });
2928
3089
  function readAdoption(cwd, options) {
2929
3090
  const target = resolveConfigTarget(cwd, options);
2930
- if (target.reason === "none" || !existsSync12(join12(cwd, target.path))) {
3091
+ if (target.reason === "none" || !existsSync12(join13(cwd, target.path))) {
2931
3092
  return NOT_ADOPTED2(target.reason === "none" ? null : target.path);
2932
3093
  }
2933
3094
  let parsed;
2934
3095
  try {
2935
- parsed = parseJsonc(readFileSync9(join12(cwd, target.path), "utf8"), target.path);
3096
+ parsed = parseJsonc(readFileSync10(join13(cwd, target.path), "utf8"), target.path);
2936
3097
  } catch (error) {
2937
3098
  const reason = error instanceof Error ? error.message : String(error);
2938
3099
  return NOT_ADOPTED2(target.path, `${target.path} could not be parsed (${reason})`);
@@ -2940,7 +3101,7 @@ function readAdoption(cwd, options) {
2940
3101
  const preset = normaliseExtends(parsed.extends).find((entry) => options.presetPattern.test(entry)) ?? null;
2941
3102
  if (preset === null) return NOT_ADOPTED2(target.path);
2942
3103
  const settings = options.settingsKey === null ? parsed : parsed[options.settingsKey];
2943
- const drift = presetOwnedKeys(settings, options.permitted);
3104
+ const drift = presetOwnedKeys(settings, options.permitted, options.presetOwns?.(preset));
2944
3105
  return {
2945
3106
  configFile: target.path,
2946
3107
  preset,
@@ -2967,14 +3128,14 @@ function readLintAdoption(cwd) {
2967
3128
 
2968
3129
  // src/roles/lint/resolve-oxlint.ts
2969
3130
  import { existsSync as existsSync13 } from "fs";
2970
- import { createRequire as createRequire2 } from "module";
2971
- import { delimiter as delimiter2, dirname as dirname4, join as join13 } from "path";
3131
+ import { createRequire as createRequire3 } from "module";
3132
+ import { delimiter as delimiter2, dirname as dirname5, join as join14 } from "path";
2972
3133
  import { fileURLToPath as fileURLToPath2 } from "url";
2973
3134
  var PACKAGE_OF = {
2974
3135
  oxlint: "oxlint",
2975
3136
  tsgolint: "oxlint-tsgolint"
2976
3137
  };
2977
- var require3 = createRequire2(import.meta.url);
3138
+ var require3 = createRequire3(import.meta.url);
2978
3139
  function resolveOxlint(cwd, name = "oxlint") {
2979
3140
  return resolveBin(cwd, name) ?? binFromOwnInstall(PACKAGE_OF[name], name);
2980
3141
  }
@@ -2984,7 +3145,7 @@ function canRunTypeAware(cwd) {
2984
3145
  function oxlintPath(cwd, env) {
2985
3146
  const shim = tsgolintShim(cwd);
2986
3147
  if (!shim) return env.PATH;
2987
- return [dirname4(shim), env.PATH].filter(Boolean).join(delimiter2);
3148
+ return [dirname5(shim), env.PATH].filter(Boolean).join(delimiter2);
2988
3149
  }
2989
3150
  function tsgolintShim(cwd) {
2990
3151
  const fromModule = resolveBin(cwd, "tsgolint");
@@ -2992,17 +3153,17 @@ function tsgolintShim(cwd) {
2992
3153
  const candidates = [];
2993
3154
  for (const owner of ["oxlint-tsgolint", "oxlint"]) {
2994
3155
  try {
2995
- const packageDir = dirname4(require3.resolve(`${owner}/package.json`));
2996
- candidates.push(join13(packageDir, "node_modules", ".bin", "tsgolint"));
2997
- candidates.push(join13(packageDir, "..", ".bin", "tsgolint"));
3156
+ const packageDir = dirname5(require3.resolve(`${owner}/package.json`));
3157
+ candidates.push(join14(packageDir, "node_modules", ".bin", "tsgolint"));
3158
+ candidates.push(join14(packageDir, "..", ".bin", "tsgolint"));
2998
3159
  } catch {
2999
3160
  }
3000
3161
  }
3001
3162
  try {
3002
- const ownRoot = dirname4(require3.resolve("@hublo/sentinel/package.json"));
3003
- candidates.push(join13(ownRoot, "node_modules", ".bin", "tsgolint"));
3163
+ const ownRoot = dirname5(require3.resolve("@hublo/sentinel/package.json"));
3164
+ candidates.push(join14(ownRoot, "node_modules", ".bin", "tsgolint"));
3004
3165
  } catch {
3005
- candidates.push(resolveBin(dirname4(fileURLToPath2(import.meta.url)), "tsgolint") ?? "");
3166
+ candidates.push(resolveBin(dirname5(fileURLToPath2(import.meta.url)), "tsgolint") ?? "");
3006
3167
  }
3007
3168
  return candidates.find((candidate) => candidate !== "" && existsSync13(candidate));
3008
3169
  }
@@ -3011,12 +3172,12 @@ function oxlintSearchPath(cwd) {
3011
3172
  }
3012
3173
 
3013
3174
  // src/roles/lint/adapters/oxlint/plan.ts
3014
- import { existsSync as existsSync15, readFileSync as readFileSync12 } from "fs";
3015
- import { join as join16 } from "path";
3175
+ import { existsSync as existsSync15, readFileSync as readFileSync13 } from "fs";
3176
+ import { join as join17 } from "path";
3016
3177
 
3017
3178
  // src/roles/lint/eslint-ignores.ts
3018
- import { existsSync as existsSync14, readFileSync as readFileSync10 } from "fs";
3019
- import { join as join14 } from "path";
3179
+ import { existsSync as existsSync14, readFileSync as readFileSync11 } from "fs";
3180
+ import { join as join15 } from "path";
3020
3181
  var IGNORE_BLOCKS = [/\bignores\s*:\s*\[([^\]]*)\]/g, /\bglobalIgnores\s*\(\s*\[([^\]]*)\]/g];
3021
3182
  var STRING_LITERAL = /['"`]([^'"`]+)['"`]/g;
3022
3183
  var OPAQUE_SOURCE = /\b(includeIgnoreFile)\s*\([^)]*\)/g;
@@ -3029,11 +3190,11 @@ function readRootEslintIgnores(root) {
3029
3190
  return { patterns: patterns.filter((pattern) => pattern.startsWith("**/")), unresolved };
3030
3191
  }
3031
3192
  function readEslintIgnores(cwd) {
3032
- const config = ESLINT_CONFIG_FILES.map((name) => join14(cwd, name)).find((path) => existsSync14(path));
3193
+ const config = ESLINT_CONFIG_FILES.map((name) => join15(cwd, name)).find((path) => existsSync14(path));
3033
3194
  if (!config) return { patterns: [], unresolved: [] };
3034
3195
  let source;
3035
3196
  try {
3036
- source = readFileSync10(config, "utf8");
3197
+ source = readFileSync11(config, "utf8");
3037
3198
  } catch {
3038
3199
  return { patterns: [], unresolved: [] };
3039
3200
  }
@@ -3087,8 +3248,8 @@ function lintPresetFor(preset) {
3087
3248
  }
3088
3249
 
3089
3250
  // src/roles/lint/rename-suppressions.ts
3090
- import { readdirSync as readdirSync2, readFileSync as readFileSync11, statSync } from "fs";
3091
- import { join as join15, relative as relative2 } from "path";
3251
+ import { readdirSync as readdirSync2, readFileSync as readFileSync12, statSync } from "fs";
3252
+ import { join as join16, relative as relative2 } from "path";
3092
3253
  var SOURCE_EXTENSIONS = [
3093
3254
  ".ts",
3094
3255
  ".tsx",
@@ -3135,7 +3296,7 @@ function* sourceFiles(dir) {
3135
3296
  return;
3136
3297
  }
3137
3298
  for (const entry of entries) {
3138
- const full = join15(dir, entry);
3299
+ const full = join16(dir, entry);
3139
3300
  let isDirectory;
3140
3301
  try {
3141
3302
  isDirectory = statSync(full).isDirectory();
@@ -3155,7 +3316,7 @@ function findSuppressionRenames(cwd, renames) {
3155
3316
  for (const file of sourceFiles(cwd)) {
3156
3317
  let content;
3157
3318
  try {
3158
- content = readFileSync11(file, "utf8");
3319
+ content = readFileSync12(file, "utf8");
3159
3320
  } catch {
3160
3321
  continue;
3161
3322
  }
@@ -3228,7 +3389,7 @@ function plan(context) {
3228
3389
  if (!hasLintPreset(context.preset)) {
3229
3390
  return {
3230
3391
  operations: [],
3231
- blocked: `sentinel does not ship a lint preset named "${context.preset}" yet (shipped: ${LINT_PRESETS.join(", ")}). Nothing was written; this module cannot adopt the lint preset until that preset ships.`
3392
+ skipped: `sentinel does not ship a lint preset named "${context.preset}" yet (shipped: ${LINT_PRESETS.join(", ")}). Nothing was written; the module keeps its current linter and the other roles adopt normally.`
3232
3393
  };
3233
3394
  }
3234
3395
  const current = presetOfVariant(presetNameFromPath(readLintAdoption(context.cwd).preset));
@@ -3273,7 +3434,7 @@ function plan(context) {
3273
3434
  keys: removableDeps
3274
3435
  });
3275
3436
  }
3276
- const eslintConfigs = ESLINT_CONFIG_FILES.filter((name) => existsSync15(join16(context.cwd, name)));
3437
+ const eslintConfigs = ESLINT_CONFIG_FILES.filter((name) => existsSync15(join17(context.cwd, name)));
3277
3438
  for (const name of eslintConfigs) operations.push({ kind: "delete", path: name });
3278
3439
  operations.push(
3279
3440
  ...nxTargetOperations({
@@ -3368,7 +3529,7 @@ function lintScripts(cwd) {
3368
3529
  function committedIgnorePatterns(cwd) {
3369
3530
  try {
3370
3531
  const parsed = parseJsonc(
3371
- readFileSync12(join16(cwd, LINT_CONFIG_FILE), "utf8"),
3532
+ readFileSync13(join17(cwd, LINT_CONFIG_FILE), "utf8"),
3372
3533
  LINT_CONFIG_FILE
3373
3534
  );
3374
3535
  return Array.isArray(parsed.ignorePatterns) ? parsed.ignorePatterns.filter((entry) => typeof entry === "string") : [];
@@ -3380,7 +3541,7 @@ function moduleEslintDependencies(cwd) {
3380
3541
  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/");
3381
3542
  let manifest;
3382
3543
  try {
3383
- manifest = JSON.parse(readFileSync12(join16(cwd, "package.json"), "utf8"));
3544
+ manifest = JSON.parse(readFileSync13(join17(cwd, "package.json"), "utf8"));
3384
3545
  } catch {
3385
3546
  return [];
3386
3547
  }
@@ -3479,7 +3640,7 @@ var OxlintAdapter = class extends BaseAdapter {
3479
3640
  this.fixPass(ctx, oxlint, env);
3480
3641
  }
3481
3642
  async run(ctx) {
3482
- if (!existsSync16(join17(ctx.cwd, LINT_CONFIG_FILE))) {
3643
+ if (!existsSync16(join18(ctx.cwd, LINT_CONFIG_FILE))) {
3483
3644
  process.stderr.write(
3484
3645
  `sentinel lint(oxlint): no ${LINT_CONFIG_FILE} in this module; run \`sentinel --init --lint\` to adopt.
3485
3646
  `
@@ -3572,9 +3733,9 @@ var OxlintAdapter = class extends BaseAdapter {
3572
3733
  if (target) return 0;
3573
3734
  let total = 0;
3574
3735
  try {
3575
- const stub = JSON.parse(readFileSync13(join17(cwd, LINT_CONFIG_FILE), "utf8"));
3736
+ const stub = JSON.parse(readFileSync14(join18(cwd, LINT_CONFIG_FILE), "utf8"));
3576
3737
  for (const entry of stub.extends ?? []) {
3577
- const preset = JSON.parse(readFileSync13(join17(cwd, entry), "utf8"));
3738
+ const preset = JSON.parse(readFileSync14(join18(cwd, entry), "utf8"));
3578
3739
  total += Object.keys(preset.rules ?? {}).length;
3579
3740
  }
3580
3741
  } catch {
@@ -3585,10 +3746,9 @@ var OxlintAdapter = class extends BaseAdapter {
3585
3746
  /** Lint health for `--report`. Deepened in a later step; today it is the run outcome. */
3586
3747
  async report(ctx) {
3587
3748
  const adoption = readLintAdoption(ctx.cwd);
3588
- if (!adoption.adopted) return { ok: true, code: 0, metrics: { adopted: false } };
3749
+ if (!adoption.adopted) return { ok: true, code: 0, metrics: {} };
3589
3750
  const oxlint = resolveOxlint(ctx.cwd);
3590
- if (!oxlint)
3591
- return { ok: false, code: 1, metrics: { adopted: true, error: "oxlint not found" } };
3751
+ if (!oxlint) return { ok: false, code: 1, metrics: { error: "oxlint not found" } };
3592
3752
  const typeAware = canRunTypeAware(ctx.cwd) ? ["--type-aware"] : [];
3593
3753
  const result = spawnSync2(oxlint, ["-c", LINT_CONFIG_FILE, ...typeAware, "-f", "json", "."], {
3594
3754
  cwd: ctx.cwd,
@@ -3596,7 +3756,7 @@ var OxlintAdapter = class extends BaseAdapter {
3596
3756
  env: { ...process.env, PATH: oxlintPath(ctx.cwd, process.env) }
3597
3757
  });
3598
3758
  if (result.error) {
3599
- return { ok: false, code: 1, metrics: { adopted: true, error: result.error.message } };
3759
+ return { ok: false, code: 1, metrics: { error: result.error.message } };
3600
3760
  }
3601
3761
  const diagnostics = parseOxlintDiagnostics(result.stdout ?? "");
3602
3762
  const errors = diagnostics.filter((entry) => entry.severity === "error").length;
@@ -3614,7 +3774,6 @@ var OxlintAdapter = class extends BaseAdapter {
3614
3774
  ok: errors === 0,
3615
3775
  code: errors === 0 ? 0 : 1,
3616
3776
  metrics: {
3617
- adopted: true,
3618
3777
  errors,
3619
3778
  warnings,
3620
3779
  rules,
@@ -3642,12 +3801,12 @@ var OxlintAdapter = class extends BaseAdapter {
3642
3801
  unresolvedPreset(cwd) {
3643
3802
  let parsed;
3644
3803
  try {
3645
- parsed = JSON.parse(readFileSync13(join17(cwd, LINT_CONFIG_FILE), "utf8"));
3804
+ parsed = JSON.parse(readFileSync14(join18(cwd, LINT_CONFIG_FILE), "utf8"));
3646
3805
  } catch {
3647
3806
  return void 0;
3648
3807
  }
3649
3808
  const targets = Array.isArray(parsed.extends) ? parsed.extends.filter((entry) => typeof entry === "string") : typeof parsed.extends === "string" ? [parsed.extends] : [];
3650
- return targets.find((target) => !existsSync16(join17(cwd, target)));
3809
+ return targets.find((target) => !existsSync16(join18(cwd, target)));
3651
3810
  }
3652
3811
  /** Announce what is not enforced, so reduced coverage is never silent. */
3653
3812
  announceDisabled(preset) {
@@ -3679,8 +3838,8 @@ function registerLint() {
3679
3838
 
3680
3839
  // src/roles/typescript/adapters/tsc/tsc.adapter.ts
3681
3840
  import { spawnSync as spawnSync3 } from "child_process";
3682
- import { existsSync as existsSync18, readFileSync as readFileSync16 } from "fs";
3683
- import { createRequire as createRequire3 } from "module";
3841
+ import { existsSync as existsSync17, readFileSync as readFileSync16 } from "fs";
3842
+ import { createRequire as createRequire4 } from "module";
3684
3843
  import { join as join20 } from "path";
3685
3844
 
3686
3845
  // src/roles/typescript/presets/base.json
@@ -3797,6 +3956,14 @@ validate2();
3797
3956
  function hasShippedPreset(preset) {
3798
3957
  return preset in LAYERS;
3799
3958
  }
3959
+ function tsPresetFor(preset) {
3960
+ const layer = LAYERS[preset];
3961
+ if (!layer) throw new Error(`no TypeScript preset ships for preset "${preset}"`);
3962
+ return {
3963
+ ...BASE.$schema === void 0 ? {} : { $schema: BASE.$schema },
3964
+ compilerOptions: { ...BASE.compilerOptions, ...layer.compilerOptions }
3965
+ };
3966
+ }
3800
3967
 
3801
3968
  // src/roles/typescript/phased-rules.ts
3802
3969
  function deferredRuleNames2() {
@@ -3811,8 +3978,17 @@ var PERMITTED_COMPILER_OPTIONS = [
3811
3978
  "outDir",
3812
3979
  "tsBuildInfoFile"
3813
3980
  ];
3814
- function presetOwnedKeys2(compilerOptions) {
3815
- return presetOwnedKeys(compilerOptions, PERMITTED_COMPILER_OPTIONS);
3981
+ function presetCompilerOptionKeys(preset) {
3982
+ if (preset === void 0 || !hasShippedPreset(preset)) return void 0;
3983
+ return Object.keys(tsPresetFor(preset).compilerOptions);
3984
+ }
3985
+ function presetOwnedKeys2(compilerOptions, preset) {
3986
+ return presetOwnedKeys(compilerOptions, PERMITTED_COMPILER_OPTIONS, presetCompilerOptionKeys(preset));
3987
+ }
3988
+ function localOnlyCompilerOptions(compilerOptions, preset) {
3989
+ const sets = presetCompilerOptionKeys(preset);
3990
+ if (sets === void 0) return [];
3991
+ return localOnlyKeys(compilerOptions, PERMITTED_COMPILER_OPTIONS, sets);
3816
3992
  }
3817
3993
 
3818
3994
  // src/roles/typescript/resolve-tsconfig-target.ts
@@ -3836,7 +4012,11 @@ function readTsconfigAdoption(cwd) {
3836
4012
  fallback: TSCONFIG_FALLBACK,
3837
4013
  presetPattern: SENTINEL_PRESET,
3838
4014
  settingsKey: "compilerOptions",
3839
- permitted: PERMITTED_COMPILER_OPTIONS
4015
+ permitted: PERMITTED_COMPILER_OPTIONS,
4016
+ // Drift is measured against what the preset SETS, not against the allowlist alone: see
4017
+ // `presetOwnedKeys`. Without this a module overriding the workspace base reads as
4018
+ // non-conformant forever, since re-running `--init` would not (and should not) fix it.
4019
+ presetOwns: (path) => presetCompilerOptionKeys(path.slice(path.lastIndexOf("/") + 1)) ?? []
3840
4020
  });
3841
4021
  }
3842
4022
 
@@ -3844,86 +4024,6 @@ function readTsconfigAdoption(cwd) {
3844
4024
  import { readFileSync as readFileSync15 } from "fs";
3845
4025
  import { join as join19 } from "path";
3846
4026
 
3847
- // src/core/config/preset-evidence.ts
3848
- import { existsSync as existsSync17, readdirSync as readdirSync3, readFileSync as readFileSync14 } from "fs";
3849
- import { join as join18 } from "path";
3850
- var PATH_SIGNALS = [
3851
- {
3852
- preset: "nest",
3853
- pattern: /(^|\/)apps\/nest\//,
3854
- evidence: "it lives under apps/nest"
3855
- }
3856
- ];
3857
- var DEPENDENCY_SIGNALS = [
3858
- { preset: "react", pattern: /^(react|react-dom|@types\/react)$|^eslint-plugin-react/ },
3859
- { preset: "svelte", pattern: /^svelte$|^@sveltejs\// },
3860
- { preset: "nest", pattern: /^@nestjs\// }
3861
- ];
3862
- function dependencyNames(cwd) {
3863
- const path = join18(cwd, "package.json");
3864
- if (!existsSync17(path)) return [];
3865
- try {
3866
- const manifest = parseJsonc(
3867
- readFileSync14(path, "utf8"),
3868
- path
3869
- );
3870
- return [
3871
- ...Object.keys(manifest.dependencies ?? {}),
3872
- ...Object.keys(manifest.devDependencies ?? {})
3873
- ];
3874
- } catch {
3875
- return [];
3876
- }
3877
- }
3878
- function declaresJsx(cwd) {
3879
- let entries;
3880
- try {
3881
- entries = readdirSync3(cwd).filter(
3882
- (name) => name.startsWith("tsconfig") && name.endsWith(".json")
3883
- );
3884
- } catch {
3885
- return false;
3886
- }
3887
- for (const name of entries) {
3888
- try {
3889
- const config = parseJsonc(
3890
- readFileSync14(join18(cwd, name), "utf8"),
3891
- name
3892
- );
3893
- if (config.compilerOptions?.jsx !== void 0) return true;
3894
- } catch {
3895
- }
3896
- }
3897
- return false;
3898
- }
3899
- function presetSignals(cwd) {
3900
- const signals = [];
3901
- const dependencies = dependencyNames(cwd);
3902
- for (const { preset, pattern } of DEPENDENCY_SIGNALS) {
3903
- const hit = dependencies.find((name) => pattern.test(name));
3904
- if (hit) signals.push({ preset, evidence: `it depends on "${hit}"` });
3905
- }
3906
- if (declaresJsx(cwd) && !signals.some((signal) => signal.preset === "react")) {
3907
- signals.push({ preset: "react", evidence: 'its tsconfig sets "jsx"' });
3908
- }
3909
- const location = cwd.replaceAll("\\", "/");
3910
- for (const { preset, pattern, evidence } of PATH_SIGNALS) {
3911
- if (pattern.test(location) && !signals.some((signal) => signal.preset === preset)) {
3912
- signals.push({ preset, evidence });
3913
- }
3914
- }
3915
- return signals;
3916
- }
3917
- function presetContradiction(declared, cwd) {
3918
- const signals = presetSignals(cwd);
3919
- if (signals.length === 0) return void 0;
3920
- if (signals.some((signal) => signal.preset === declared)) return void 0;
3921
- const [first] = signals;
3922
- if (!first) return void 0;
3923
- const evidence = signals.map((signal) => signal.evidence).join(", and ");
3924
- return `--preset ${declared} does not match this module: ${evidence}, which makes it "${first.preset}". Nothing was written. Re-run with --preset ${first.preset}, or adopt from the module you actually meant.`;
3925
- }
3926
-
3927
4027
  // src/roles/typescript/typecheck-script.ts
3928
4028
  var SENTINEL_TYPECHECK_COMMAND = "sentinel --run --typescript";
3929
4029
  var TYPECHECK_SCRIPT_NAME = "typecheck";
@@ -3964,7 +4064,7 @@ function planAdoption(context) {
3964
4064
  if (!hasShippedPreset(context.preset)) {
3965
4065
  return {
3966
4066
  operations: [],
3967
- blocked: `sentinel does not ship a TypeScript preset named "${context.preset}" yet (shipped: ${SHIPPED_PRESETS.join(", ")}). Nothing was written; this module cannot adopt the TypeScript preset until that preset ships.`
4067
+ skipped: `sentinel does not ship a TypeScript preset named "${context.preset}" yet (shipped: ${SHIPPED_PRESETS.join(", ")}). Nothing was written, and the module keeps its own tsconfig; the other roles adopt normally.`
3968
4068
  };
3969
4069
  }
3970
4070
  const current = declaredPreset(context.cwd);
@@ -3974,10 +4074,6 @@ function planAdoption(context) {
3974
4074
  blocked: `this module is already adopted as "${current}", and --preset says "${context.preset}". Nothing was written. If the change is deliberate, remove the sentinel preset from the tsconfig \`extends\` chain and re-run; otherwise re-run with --preset ${current}.`
3975
4075
  };
3976
4076
  }
3977
- const contradiction = presetContradiction(context.preset, context.cwd);
3978
- if (contradiction !== void 0) {
3979
- return { operations: [], blocked: contradiction };
3980
- }
3981
4077
  if (!hasSourceFiles(context.cwd, TYPECHECKABLE_EXTENSIONS)) {
3982
4078
  return {
3983
4079
  operations: [],
@@ -4015,7 +4111,8 @@ function planAdoption(context) {
4015
4111
  target.path
4016
4112
  );
4017
4113
  const extendsChain = composeExtends(existing.extends, preset);
4018
- const drift = presetOwnedKeys2(existing.compilerOptions);
4114
+ const drift = presetOwnedKeys2(existing.compilerOptions, context.preset);
4115
+ const keptLocal = localOnlyCompilerOptions(existing.compilerOptions, context.preset);
4019
4116
  const operations = [
4020
4117
  { kind: "merge-json", path: target.path, value: { extends: extendsChain } }
4021
4118
  ];
@@ -4030,6 +4127,11 @@ function planAdoption(context) {
4030
4127
  });
4031
4128
  notes.push(`stripped preset-owned compilerOptions: ${drift.join(", ")}`);
4032
4129
  }
4130
+ if (keptLocal.length > 0) {
4131
+ notes.push(
4132
+ `KEPT this module's own compilerOptions (${keptLocal.join(", ")}): the preset does not set them, so they are yours rather than drift. Removing them would not hand the decision to the preset, it would hand it to the workspace tsconfig this module extends`
4133
+ );
4134
+ }
4033
4135
  operations.push(addScript, ...nxTargets);
4034
4136
  const existingCheck = moduleScripts(context.cwd)[TYPECHECK_SCRIPT_NAME];
4035
4137
  if (keepsForeignChecker(existingCheck)) {
@@ -4062,7 +4164,7 @@ function parseDiagnostics(output) {
4062
4164
  return diagnostics;
4063
4165
  }
4064
4166
  var PHASED_STRICTNESS_WARNING = `sentinel typescript: phase 1 (non-breaking) \u2014 deferred: ${deferredRuleNames2().join(", ")}. Enabled centrally in a later wave; run \`sentinel --inspect --typescript\` for the list, or \`sentinel --run --typescript -- --noImplicitAny\` to measure what one would cost today.`;
4065
- var TscAdapter = class extends BaseAdapter {
4167
+ var TscAdapter = class _TscAdapter extends BaseAdapter {
4066
4168
  target = "typescript";
4067
4169
  runner = "tsc";
4068
4170
  /**
@@ -4156,7 +4258,7 @@ var TscAdapter = class extends BaseAdapter {
4156
4258
  );
4157
4259
  if (preset === void 0) return void 0;
4158
4260
  try {
4159
- createRequire3(join20(cwd, "noop.js")).resolve(preset);
4261
+ createRequire4(join20(cwd, "noop.js")).resolve(preset);
4160
4262
  return void 0;
4161
4263
  } catch {
4162
4264
  return preset;
@@ -4170,6 +4272,13 @@ var TscAdapter = class extends BaseAdapter {
4170
4272
  );
4171
4273
  return { ok: true, code: 0 };
4172
4274
  }
4275
+ if (!readTsconfigAdoption(ctx.cwd).adopted) {
4276
+ process.stderr.write(
4277
+ `sentinel typescript(tsc): no sentinel preset in this module's tsconfig; run \`sentinel --init --typescript\` to adopt.
4278
+ `
4279
+ );
4280
+ return { ok: true, code: 0 };
4281
+ }
4173
4282
  const missing = this.missingPresetPackage(ctx.cwd);
4174
4283
  if (missing) {
4175
4284
  process.stderr.write(
@@ -4261,7 +4370,7 @@ var TscAdapter = class extends BaseAdapter {
4261
4370
  * check.
4262
4371
  */
4263
4372
  typecheckTarget(cwd) {
4264
- if (existsSync18(join20(cwd, "tsconfig.json"))) return "tsconfig.json";
4373
+ if (existsSync17(join20(cwd, "tsconfig.json"))) return "tsconfig.json";
4265
4374
  const target = resolveTsconfigTarget(cwd);
4266
4375
  return target.reason === "none" ? null : target.path;
4267
4376
  }
@@ -4295,10 +4404,23 @@ var TscAdapter = class extends BaseAdapter {
4295
4404
  * 7006/7031/7053/… ), the signal that drives the noImplicitAny migration. No
4296
4405
  * tsconfig is a clean, empty report.
4297
4406
  */
4407
+ /**
4408
+ * The metrics for a module this role has nothing to say about. Every KEY the full path
4409
+ * emits, so a consumer never sees a row whose shape depends on whether the module adopted.
4410
+ */
4411
+ static NOTHING_TO_REPORT = {
4412
+ errors: 0,
4413
+ implicitAny: "deferred",
4414
+ diagnostics: [],
4415
+ diagnosticsTruncated: false
4416
+ };
4298
4417
  async report(ctx) {
4299
4418
  const config = this.typecheckTarget(ctx.cwd);
4300
4419
  if (!config) {
4301
- return { ok: true, code: 0, metrics: { errors: 0, implicitAny: "deferred", diagnostics: [] } };
4420
+ return { ok: true, code: 0, metrics: _TscAdapter.NOTHING_TO_REPORT };
4421
+ }
4422
+ if (!readTsconfigAdoption(ctx.cwd).adopted) {
4423
+ return { ok: true, code: 0, metrics: _TscAdapter.NOTHING_TO_REPORT };
4302
4424
  }
4303
4425
  const tsc = resolveBin(ctx.cwd, "tsc") ?? "tsc";
4304
4426
  const result = spawnSync3(tsc, ["-b", config], { cwd: ctx.cwd, encoding: "utf8" });
@@ -4451,7 +4573,7 @@ function replaceLines(current, replacements) {
4451
4573
  }
4452
4574
 
4453
4575
  // src/core/apply-plan.ts
4454
- import { existsSync as existsSync19, readFileSync as readFileSync17, renameSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
4576
+ import { existsSync as existsSync18, readFileSync as readFileSync17, renameSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
4455
4577
  import { resolve as resolve3, sep } from "path";
4456
4578
  import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
4457
4579
 
@@ -4470,7 +4592,7 @@ function resolveWithinRoot(cwd, relativePath) {
4470
4592
  return absolutePath;
4471
4593
  }
4472
4594
  function readIfExists(absolutePath) {
4473
- return existsSync19(absolutePath) ? readFileSync17(absolutePath, "utf8") : void 0;
4595
+ return existsSync18(absolutePath) ? readFileSync17(absolutePath, "utf8") : void 0;
4474
4596
  }
4475
4597
  function* leaves(value, prefix = []) {
4476
4598
  for (const [key, keyValue] of Object.entries(value)) {
@@ -4587,6 +4709,86 @@ function applyPlan(cwd, plan2) {
4587
4709
  return changed.map((file) => ({ path: file.path, deleted: Boolean(file.deleted) }));
4588
4710
  }
4589
4711
 
4712
+ // src/core/config/preset-evidence.ts
4713
+ import { existsSync as existsSync19, readdirSync as readdirSync3, readFileSync as readFileSync18 } from "fs";
4714
+ import { join as join21 } from "path";
4715
+ var PATH_SIGNALS = [
4716
+ {
4717
+ preset: "nest",
4718
+ pattern: /(^|\/)apps\/nest\//,
4719
+ evidence: "it lives under apps/nest"
4720
+ }
4721
+ ];
4722
+ var DEPENDENCY_SIGNALS = [
4723
+ { preset: "react", pattern: /^(react|react-dom|@types\/react)$|^eslint-plugin-react/ },
4724
+ { preset: "svelte", pattern: /^svelte$|^@sveltejs\// },
4725
+ { preset: "nest", pattern: /^@nestjs\// }
4726
+ ];
4727
+ function dependencyNames(cwd) {
4728
+ const path = join21(cwd, "package.json");
4729
+ if (!existsSync19(path)) return [];
4730
+ try {
4731
+ const manifest = parseJsonc(
4732
+ readFileSync18(path, "utf8"),
4733
+ path
4734
+ );
4735
+ return [
4736
+ ...Object.keys(manifest.dependencies ?? {}),
4737
+ ...Object.keys(manifest.devDependencies ?? {})
4738
+ ];
4739
+ } catch {
4740
+ return [];
4741
+ }
4742
+ }
4743
+ function declaresJsx(cwd) {
4744
+ let entries;
4745
+ try {
4746
+ entries = readdirSync3(cwd).filter(
4747
+ (name) => name.startsWith("tsconfig") && name.endsWith(".json")
4748
+ );
4749
+ } catch {
4750
+ return false;
4751
+ }
4752
+ for (const name of entries) {
4753
+ try {
4754
+ const config = parseJsonc(
4755
+ readFileSync18(join21(cwd, name), "utf8"),
4756
+ name
4757
+ );
4758
+ if (config.compilerOptions?.jsx !== void 0) return true;
4759
+ } catch {
4760
+ }
4761
+ }
4762
+ return false;
4763
+ }
4764
+ function presetSignals(cwd) {
4765
+ const signals = [];
4766
+ const dependencies = dependencyNames(cwd);
4767
+ for (const { preset, pattern } of DEPENDENCY_SIGNALS) {
4768
+ const hit = dependencies.find((name) => pattern.test(name));
4769
+ if (hit) signals.push({ preset, evidence: `it depends on "${hit}"` });
4770
+ }
4771
+ if (declaresJsx(cwd) && !signals.some((signal) => signal.preset === "react")) {
4772
+ signals.push({ preset: "react", evidence: 'its tsconfig sets "jsx"' });
4773
+ }
4774
+ const location = cwd.replaceAll("\\", "/");
4775
+ for (const { preset, pattern, evidence } of PATH_SIGNALS) {
4776
+ if (pattern.test(location) && !signals.some((signal) => signal.preset === preset)) {
4777
+ signals.push({ preset, evidence });
4778
+ }
4779
+ }
4780
+ return signals;
4781
+ }
4782
+ function presetContradiction(declared, cwd) {
4783
+ const signals = presetSignals(cwd);
4784
+ if (signals.length === 0) return void 0;
4785
+ if (signals.some((signal) => signal.preset === declared)) return void 0;
4786
+ const [first] = signals;
4787
+ if (!first) return void 0;
4788
+ const evidence = signals.map((signal) => signal.evidence).join(", and ");
4789
+ return `--preset ${declared} does not match this module: ${evidence}, which makes it "${first.preset}". Nothing was written. Re-run with --preset ${first.preset}, or adopt from the module you actually meant.`;
4790
+ }
4791
+
4590
4792
  // src/core/dispatch.ts
4591
4793
  function resolveFlavour(opts) {
4592
4794
  if (opts.preset) return opts.preset;
@@ -4646,6 +4848,12 @@ async function dispatch(opts) {
4646
4848
  const detected = resolveFlavour(opts);
4647
4849
  const adapter = resolve(opts.target, detected, opts.runner);
4648
4850
  const preset = opts.preset ?? adapter.declaredPreset?.(opts.cwd) ?? detected;
4851
+ const contradiction = presetContradiction(preset, opts.cwd);
4852
+ if (contradiction !== void 0) {
4853
+ process.stderr.write(`sentinel (${opts.target}): ${contradiction}
4854
+ `);
4855
+ return 1;
4856
+ }
4649
4857
  const context = { cwd: opts.cwd, preset };
4650
4858
  const plan2 = await adapter.plan(context);
4651
4859
  if (plan2.blocked) {
@@ -4694,8 +4902,8 @@ export {
4694
4902
  WORKSPACE_ROOT_MARKER,
4695
4903
  findWorkspaceRoot,
4696
4904
  ensureWorkspacePrep,
4697
- resolveBin,
4698
4905
  palette,
4906
+ resolveBin,
4699
4907
  VERBS,
4700
4908
  TARGETS,
4701
4909
  PRESET_NAMES,
@@ -4704,4 +4912,4 @@ export {
4704
4912
  detectFramework,
4705
4913
  dispatch
4706
4914
  };
4707
- //# sourceMappingURL=chunk-NAUACDXT.js.map
4915
+ //# sourceMappingURL=chunk-FA6N4XCP.js.map
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  registerAdapters,
8
8
  resolve,
9
9
  setDefaultRunner
10
- } from "./chunk-NAUACDXT.js";
10
+ } from "./chunk-FA6N4XCP.js";
11
11
  export {
12
12
  BaseAdapter,
13
13
  all,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hublo/sentinel",
3
- "version": "1.1.0-alpha.5",
3
+ "version": "1.1.0-alpha.7",
4
4
  "description": "One CLI that guards code health across Hublo repos: shared lint/typescript/build/test presets, static & dynamic analysis, and architecture checks.",
5
5
  "type": "module",
6
6
  "license": "MIT",