@hublo/sentinel 1.4.0-alpha.36 → 1.4.0-alpha.37

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.
@@ -15,10 +15,10 @@ import {
15
15
  palette,
16
16
  registerAdapters,
17
17
  resolve
18
- } from "../chunk-ZMUTKBZ2.js";
18
+ } from "../chunk-2N5D63HC.js";
19
19
  import {
20
20
  resolveContext
21
- } from "../chunk-OORAEYSU.js";
21
+ } from "../chunk-YMSI5ZCB.js";
22
22
  import "../chunk-O7REVMOC.js";
23
23
  import "../chunk-QXFCZON7.js";
24
24
  import {
@@ -145,7 +145,7 @@ async function runValidate(ctx) {
145
145
  );
146
146
  }
147
147
  if (!ctx.targets.includes("test")) return 1;
148
- const { validateModuleTests } = await import("../validate-2RTPM5EB.js");
148
+ const { validateModuleTests } = await import("../validate-MTAEHEMX.js");
149
149
  const outcomes = modules.flatMap(
150
150
  (module) => validateModuleTests(module.name, module.root)
151
151
  );
@@ -1,9 +1,15 @@
1
1
  import {
2
2
  ALWAYS_LOCAL_KEYS,
3
3
  BaseAdapter,
4
+ ESLINT_CONFIG_FILES,
4
5
  FORMAT_CONFIG_FILE,
5
6
  FORMAT_DEFAULT_IGNORES,
6
7
  FORMAT_SCRIPT_NAME,
8
+ LINT_BASELINE_FILE,
9
+ LINT_BASELINE_SPECIFIER,
10
+ LINT_CONFIG_FILE,
11
+ LINT_MEASURE_FILE,
12
+ LINT_SCRIPT_NAME,
7
13
  PERMITTED_LOCAL_KEYS,
8
14
  PRETTIER_CONFIG_FILES,
9
15
  PROVENANCE_KEY,
@@ -11,22 +17,43 @@ import {
11
17
  adoptedPackageName,
12
18
  binFromOwnInstall,
13
19
  binSearchPath,
20
+ canRunTypeAware,
21
+ describeHolds,
22
+ extendsWithBaseline,
23
+ extendsWithPreset,
14
24
  formatPresetFor,
15
25
  formatPresetName,
16
26
  formatTargets,
27
+ heldRules,
28
+ holdsFrom,
29
+ isSentinelPreset,
30
+ lintTarget,
31
+ localOnlyKeys,
17
32
  manifestOperation,
18
33
  moduleScripts,
19
34
  nxTargetOperations,
20
35
  oxfmtRejects,
36
+ oxlintPath,
37
+ oxlintSearchPath,
38
+ presetNameFromPath,
39
+ presetOfVariant,
40
+ presetOwnedKeys,
41
+ presetPath,
42
+ presetVariant,
21
43
  projectTargets,
44
+ readAdoption,
22
45
  readFormatAdoption,
46
+ readLintAdoption,
47
+ renderBaseline,
23
48
  resolveBin,
49
+ resolveConfigTarget,
24
50
  resolveOxfmt,
51
+ resolveOxlint,
25
52
  rewriteToolchainImports,
26
53
  selfCommand,
27
54
  splitToolArgs,
28
55
  walk
29
- } from "./chunk-OORAEYSU.js";
56
+ } from "./chunk-YMSI5ZCB.js";
30
57
  import {
31
58
  parseJsonc
32
59
  } from "./chunk-QXFCZON7.js";
@@ -1897,8 +1924,8 @@ function registerFormat() {
1897
1924
 
1898
1925
  // src/roles/lint/adapters/oxlint/oxlint.adapter.ts
1899
1926
  import { spawnSync as spawnSync4 } from "node:child_process";
1900
- import { existsSync as existsSync16, readFileSync as readFileSync16, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
1901
- import { join as join20 } from "node:path";
1927
+ import { existsSync as existsSync12, readFileSync as readFileSync13, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
1928
+ import { join as join16 } from "node:path";
1902
1929
 
1903
1930
  // src/core/config/deferred-rules.ts
1904
1931
  function deferredRuleNames(rules) {
@@ -1957,104 +1984,6 @@ function describeFixOutcome(outcome, toolLabel) {
1957
1984
  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.`;
1958
1985
  }
1959
1986
 
1960
- // src/roles/lint/module-baseline.ts
1961
- import { existsSync as existsSync9, readFileSync as readFileSync9 } from "node:fs";
1962
- import { join as join12 } from "node:path";
1963
- var LINT_BASELINE_FILE = ".oxlintrc.baseline.json";
1964
- var LINT_MEASURE_FILE = ".oxlintrc.measure.json";
1965
- var LINT_BASELINE_SPECIFIER = `./${LINT_BASELINE_FILE}`;
1966
- function holdsFrom(errorCounts) {
1967
- return [...errorCounts].map(([rule, count]) => ({ rule, count })).sort((left, right) => right.count - left.count || left.rule.localeCompare(right.rule));
1968
- }
1969
- function renderBaseline(holds, version) {
1970
- const entries = holds.map(({ rule }) => ` ${JSON.stringify(rule)}: "warn"`).join(",\n");
1971
- return [
1972
- "{",
1973
- ` // Written by @hublo/sentinel@${version}. Do not edit by hand: \`sentinel --init --lint\``,
1974
- " // regenerates it from a fresh measurement, and a rule you have since fixed drops out",
1975
- " // and returns to `error` on its own.",
1976
- " //",
1977
- " // These rules are held at `warn` because this module ALREADY violated them when it",
1978
- " // adopted. They still run and still report, so the build cannot fail on them.",
1979
- " //",
1980
- " // The hold is per RULE, not per occurrence: oxlint cannot freeze a specific list of",
1981
- " // violations, so a NEW violation of one of these rules is covered too, and will only",
1982
- " // warn. That is the cost of adopting without a red build, and the reason to clear this",
1983
- " // file rather than live with it.",
1984
- " //",
1985
- " // `sentinel --inspect --lint` lists what is held; `sentinel --run --lint --json` counts",
1986
- " // what is left, per rule. Fix them and re-run `--init --lint` to get the severity back.",
1987
- ' "rules": {',
1988
- entries,
1989
- " }",
1990
- "}",
1991
- ""
1992
- ].join("\n");
1993
- }
1994
- function extendsWithBaseline(current, hasHolds) {
1995
- const withoutBaseline = current.filter((entry) => entry !== LINT_BASELINE_SPECIFIER);
1996
- return hasHolds ? [...withoutBaseline, LINT_BASELINE_SPECIFIER] : withoutBaseline;
1997
- }
1998
- function describeHolds(holds) {
1999
- if (holds.length === 0) return "";
2000
- const total = holds.reduce((sum, hold) => sum + hold.count, 0);
2001
- const listed = holds.slice(0, 5).map(({ rule, count }) => `${rule} (${count})`).join(", ");
2002
- const rest = holds.length > 5 ? `, and ${holds.length - 5} more` : "";
2003
- 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`;
2004
- }
2005
- function heldRules(cwd) {
2006
- const path = join12(cwd, LINT_BASELINE_FILE);
2007
- if (!existsSync9(path)) return [];
2008
- try {
2009
- const parsed = parseJsonc(
2010
- readFileSync9(path, "utf8"),
2011
- LINT_BASELINE_FILE
2012
- );
2013
- return Object.keys(parsed.rules ?? {});
2014
- } catch {
2015
- return [];
2016
- }
2017
- }
2018
-
2019
- // src/roles/lint/config-policy.ts
2020
- var LINT_CONFIG_FILE = ".oxlintrc.json";
2021
- var LINT_SCRIPT_NAME = "lint";
2022
- var PRESET_DIR = "./node_modules/@hublo/sentinel/lint";
2023
- function presetPath(preset) {
2024
- return `${PRESET_DIR}/${preset}.json`;
2025
- }
2026
- function isSentinelPreset(entry) {
2027
- return /@hublo\/sentinel\/(lint|oxlint)\//.test(entry);
2028
- }
2029
- function extendsWithPreset(current, variant) {
2030
- const own = presetPath(variant);
2031
- const others = [...new Set(current.filter((entry) => entry !== own && !isSentinelPreset(entry)))];
2032
- const baseline = others.filter((entry) => entry === LINT_BASELINE_SPECIFIER);
2033
- const rest = others.filter((entry) => entry !== LINT_BASELINE_SPECIFIER);
2034
- return [own, ...rest, ...baseline];
2035
- }
2036
- function presetVariant(preset, cwd) {
2037
- const normalised = cwd.replaceAll("\\", "/");
2038
- return preset === "react" && /(^|\/)libs\//.test(normalised) ? "react-lib" : preset;
2039
- }
2040
- function presetOfVariant(variant) {
2041
- return variant === "react-lib" ? "react" : variant;
2042
- }
2043
- var SENTINEL_LINT_PRESET = /@hublo\/sentinel\/(?:lint|oxlint)\/([a-z-]+)\.json$/;
2044
- function presetNameFromPath(preset) {
2045
- return preset ? SENTINEL_LINT_PRESET.exec(preset)?.[1] ?? void 0 : void 0;
2046
- }
2047
- var PERMITTED_LOCAL_KEYS2 = ["extends", "ignorePatterns"];
2048
- var ESLINT_CONFIG_FILES = [
2049
- "eslint.config.js",
2050
- "eslint.config.mjs",
2051
- "eslint.config.cjs",
2052
- "eslint.config.ts"
2053
- ];
2054
- function lintTarget() {
2055
- return { cache: true, inputs: ["default", "^default", `{projectRoot}/${LINT_CONFIG_FILE}`] };
2056
- }
2057
-
2058
1987
  // src/roles/lint/presets/nest.json
2059
1988
  var nest_default = {
2060
1989
  plugins: ["typescript", "jest", "import", "unicorn", "oxc", "node"],
@@ -3833,14 +3762,14 @@ function downgradedRulesFor(preset) {
3833
3762
  }
3834
3763
 
3835
3764
  // src/roles/lint/extra-layers.ts
3836
- import { existsSync as existsSync10, readFileSync as readFileSync10 } from "node:fs";
3837
- import { isAbsolute, join as join13, resolve as resolve2 } from "node:path";
3765
+ import { existsSync as existsSync9, readFileSync as readFileSync9 } from "node:fs";
3766
+ import { isAbsolute, join as join12, resolve as resolve2 } from "node:path";
3838
3767
  function ruleCount(cwd, specifier) {
3839
3768
  const path = isAbsolute(specifier) ? specifier : resolve2(cwd, specifier);
3840
- if (!existsSync10(path)) return void 0;
3769
+ if (!existsSync9(path)) return void 0;
3841
3770
  try {
3842
3771
  const parsed = parseJsonc(
3843
- readFileSync10(path, "utf8"),
3772
+ readFileSync9(path, "utf8"),
3844
3773
  specifier
3845
3774
  );
3846
3775
  return Object.keys(parsed.rules ?? {}).length;
@@ -3860,7 +3789,7 @@ function extraLayers(cwd, extendsList) {
3860
3789
  function committedExtendsList(cwd, configFile) {
3861
3790
  try {
3862
3791
  const parsed = parseJsonc(
3863
- readFileSync10(join13(cwd, configFile), "utf8"),
3792
+ readFileSync9(join12(cwd, configFile), "utf8"),
3864
3793
  configFile
3865
3794
  );
3866
3795
  return Array.isArray(parsed.extends) ? parsed.extends.filter((entry) => typeof entry === "string") : [];
@@ -3918,166 +3847,13 @@ function errorCountsByConfigRule(stdout) {
3918
3847
  return counts;
3919
3848
  }
3920
3849
 
3921
- // src/core/config/read-adoption.ts
3922
- import { existsSync as existsSync12, readFileSync as readFileSync12 } from "node:fs";
3923
- import { join as join15 } from "node:path";
3924
-
3925
- // src/core/config/owned-keys.ts
3926
- function presetOwnedKeys(config, permitted, presetSets) {
3927
- if (!config) return [];
3928
- return Object.keys(config).filter(
3929
- (key) => !permitted.includes(key) && (presetSets === void 0 || presetSets.includes(key))
3930
- );
3931
- }
3932
- function localOnlyKeys(config, permitted, presetSets) {
3933
- if (!config) return [];
3934
- return Object.keys(config).filter((key) => !permitted.includes(key) && !presetSets.includes(key));
3935
- }
3936
-
3937
- // src/core/config/resolve-config-target.ts
3938
- import { existsSync as existsSync11, readFileSync as readFileSync11 } from "node:fs";
3939
- import { join as join14 } from "node:path";
3940
- function readExtends(absolutePath) {
3941
- let parsed;
3942
- try {
3943
- parsed = parseJsonc(readFileSync11(absolutePath, "utf8"), absolutePath);
3944
- } catch {
3945
- return [];
3946
- }
3947
- if (typeof parsed.extends === "string") return [parsed.extends];
3948
- if (Array.isArray(parsed.extends)) {
3949
- return parsed.extends.filter((entry) => typeof entry === "string");
3950
- }
3951
- return [];
3952
- }
3953
- function resolveConfigTarget(moduleDir, { candidates, markers, fallback }) {
3954
- let existing;
3955
- let existingExtendsSomething = false;
3956
- for (const candidate of candidates) {
3957
- const absolutePath = join14(moduleDir, candidate);
3958
- if (!existsSync11(absolutePath)) continue;
3959
- const chain = readExtends(absolutePath);
3960
- if (existing === void 0) {
3961
- existing = candidate;
3962
- existingExtendsSomething = chain.length > 0;
3963
- }
3964
- const extendsBase = chain.some((value) => markers.some((marker) => value.includes(marker)));
3965
- if (extendsBase) return { path: candidate, reason: "extends-base" };
3966
- }
3967
- if (existing) {
3968
- return { path: existing, reason: existingExtendsSomething ? "other-chain" : "no-extends" };
3969
- }
3970
- return { path: fallback, reason: "none" };
3971
- }
3972
-
3973
- // src/core/config/read-adoption.ts
3974
- function normaliseExtends(value) {
3975
- if (typeof value === "string") return [value];
3976
- if (Array.isArray(value)) {
3977
- return value.filter((entry) => typeof entry === "string");
3978
- }
3979
- return [];
3980
- }
3981
- var NOT_ADOPTED2 = (configFile, unreadable = null) => ({
3982
- configFile,
3983
- preset: null,
3984
- adopted: false,
3985
- conformant: false,
3986
- drift: [],
3987
- unreadable
3988
- });
3989
- function readAdoption(cwd, options) {
3990
- const target = resolveConfigTarget(cwd, options);
3991
- if (target.reason === "none" || !existsSync12(join15(cwd, target.path))) {
3992
- return NOT_ADOPTED2(target.reason === "none" ? null : target.path);
3993
- }
3994
- let parsed;
3995
- try {
3996
- parsed = parseJsonc(readFileSync12(join15(cwd, target.path), "utf8"), target.path);
3997
- } catch (error) {
3998
- const reason = error instanceof Error ? error.message : String(error);
3999
- return NOT_ADOPTED2(target.path, `${target.path} could not be parsed (${reason})`);
4000
- }
4001
- const preset = normaliseExtends(parsed.extends).find((entry) => options.presetPattern.test(entry)) ?? null;
4002
- if (preset === null) return NOT_ADOPTED2(target.path);
4003
- const settings = options.settingsKey === null ? parsed : parsed[options.settingsKey];
4004
- const drift = presetOwnedKeys(settings, options.permitted, options.presetOwns?.(preset));
4005
- return {
4006
- configFile: target.path,
4007
- preset,
4008
- adopted: true,
4009
- conformant: drift.length === 0,
4010
- drift,
4011
- unreadable: null
4012
- };
4013
- }
4014
-
4015
- // src/roles/lint/read-adoption.ts
4016
- function readLintAdoption(cwd) {
4017
- return readAdoption(cwd, {
4018
- candidates: [LINT_CONFIG_FILE],
4019
- markers: ["@hublo/sentinel/oxlint/"],
4020
- fallback: LINT_CONFIG_FILE,
4021
- presetPattern: SENTINEL_LINT_PRESET,
4022
- // Drift for lint is any TOP-LEVEL key beyond `extends`, so the settings the shared
4023
- // reader inspects are the config's own root rather than a nested options object.
4024
- settingsKey: null,
4025
- permitted: PERMITTED_LOCAL_KEYS2
4026
- });
4027
- }
4028
-
4029
- // src/roles/lint/resolve-oxlint.ts
4030
- import { existsSync as existsSync13 } from "node:fs";
4031
- import { createRequire } from "node:module";
4032
- import { delimiter, dirname, join as join16 } from "node:path";
4033
- import { fileURLToPath } from "node:url";
4034
- var PACKAGE_OF = {
4035
- oxlint: "oxlint",
4036
- tsgolint: "oxlint-tsgolint"
4037
- };
4038
- var require2 = createRequire(import.meta.url);
4039
- function resolveOxlint(cwd, name = "oxlint") {
4040
- return resolveBin(cwd, name) ?? binFromOwnInstall(PACKAGE_OF[name], name);
4041
- }
4042
- function canRunTypeAware(cwd) {
4043
- return tsgolintShim(cwd) !== void 0;
4044
- }
4045
- function oxlintPath(cwd, env) {
4046
- const shim = tsgolintShim(cwd);
4047
- if (!shim) return env.PATH;
4048
- return [dirname(shim), env.PATH].filter(Boolean).join(delimiter);
4049
- }
4050
- function tsgolintShim(cwd) {
4051
- const fromModule = resolveBin(cwd, "tsgolint");
4052
- if (fromModule) return fromModule;
4053
- const candidates = [];
4054
- for (const owner of ["oxlint-tsgolint", "oxlint"]) {
4055
- try {
4056
- const packageDir = dirname(require2.resolve(`${owner}/package.json`));
4057
- candidates.push(join16(packageDir, "node_modules", ".bin", "tsgolint"));
4058
- candidates.push(join16(packageDir, "..", ".bin", "tsgolint"));
4059
- } catch {
4060
- }
4061
- }
4062
- try {
4063
- const ownRoot = dirname(require2.resolve("@hublo/sentinel/package.json"));
4064
- candidates.push(join16(ownRoot, "node_modules", ".bin", "tsgolint"));
4065
- } catch {
4066
- candidates.push(resolveBin(dirname(fileURLToPath(import.meta.url)), "tsgolint") ?? "");
4067
- }
4068
- return candidates.find((candidate) => candidate !== "" && existsSync13(candidate));
4069
- }
4070
- function oxlintSearchPath(cwd) {
4071
- return binSearchPath(cwd);
4072
- }
4073
-
4074
3850
  // src/roles/lint/adapters/oxlint/plan.ts
4075
- import { existsSync as existsSync15, readFileSync as readFileSync15 } from "node:fs";
4076
- import { join as join19 } from "node:path";
3851
+ import { existsSync as existsSync11, readFileSync as readFileSync12 } from "node:fs";
3852
+ import { join as join15 } from "node:path";
4077
3853
 
4078
3854
  // src/roles/lint/eslint-ignores.ts
4079
- import { existsSync as existsSync14, readFileSync as readFileSync13 } from "node:fs";
4080
- import { join as join17 } from "node:path";
3855
+ import { existsSync as existsSync10, readFileSync as readFileSync10 } from "node:fs";
3856
+ import { join as join13 } from "node:path";
4081
3857
  import { parseSync } from "oxc-parser";
4082
3858
  var OPAQUE_SOURCE = /\b(includeIgnoreFile)\s*\([^)]*\)/g;
4083
3859
  function toOxlintPattern(pattern) {
@@ -4089,11 +3865,11 @@ function readRootEslintIgnores(root) {
4089
3865
  return { patterns: patterns.filter((pattern) => pattern.startsWith("**/")), unresolved };
4090
3866
  }
4091
3867
  function readEslintIgnores(cwd) {
4092
- const config = ESLINT_CONFIG_FILES.map((name) => join17(cwd, name)).find((path) => existsSync14(path));
3868
+ const config = ESLINT_CONFIG_FILES.map((name) => join13(cwd, name)).find((path) => existsSync10(path));
4093
3869
  if (!config) return { patterns: [], unresolved: [] };
4094
3870
  let source;
4095
3871
  try {
4096
- source = readFileSync13(config, "utf8");
3872
+ source = readFileSync10(config, "utf8");
4097
3873
  } catch {
4098
3874
  return { patterns: [], unresolved: [] };
4099
3875
  }
@@ -4184,8 +3960,8 @@ function lintPresetFor(preset) {
4184
3960
  }
4185
3961
 
4186
3962
  // src/roles/lint/rename-suppressions.ts
4187
- import { readdirSync as readdirSync2, readFileSync as readFileSync14, statSync as statSync3 } from "node:fs";
4188
- import { join as join18, relative as relative3 } from "node:path";
3963
+ import { readdirSync as readdirSync2, readFileSync as readFileSync11, statSync as statSync3 } from "node:fs";
3964
+ import { join as join14, relative as relative3 } from "node:path";
4189
3965
  var SOURCE_EXTENSIONS = [
4190
3966
  ".ts",
4191
3967
  ".tsx",
@@ -4232,7 +4008,7 @@ function* sourceFiles(dir) {
4232
4008
  return;
4233
4009
  }
4234
4010
  for (const entry of entries) {
4235
- const full = join18(dir, entry);
4011
+ const full = join14(dir, entry);
4236
4012
  let isDirectory;
4237
4013
  try {
4238
4014
  isDirectory = statSync3(full).isDirectory();
@@ -4252,7 +4028,7 @@ function findSuppressionRenames(cwd, renames) {
4252
4028
  for (const file of sourceFiles(cwd)) {
4253
4029
  let content;
4254
4030
  try {
4255
- content = readFileSync14(file, "utf8");
4031
+ content = readFileSync11(file, "utf8");
4256
4032
  } catch {
4257
4033
  continue;
4258
4034
  }
@@ -4380,7 +4156,7 @@ function plan2(context) {
4380
4156
  keys: removableDeps
4381
4157
  });
4382
4158
  }
4383
- const eslintConfigs = ESLINT_CONFIG_FILES.filter((name) => existsSync15(join19(context.cwd, name)));
4159
+ const eslintConfigs = ESLINT_CONFIG_FILES.filter((name) => existsSync11(join15(context.cwd, name)));
4384
4160
  for (const name of eslintConfigs) operations.push({ kind: "delete", path: name });
4385
4161
  operations.push(
4386
4162
  ...nxTargetOperations({
@@ -4478,7 +4254,7 @@ function lintScripts(cwd) {
4478
4254
  function committedExtends(cwd) {
4479
4255
  try {
4480
4256
  const parsed = parseJsonc(
4481
- readFileSync15(join19(cwd, LINT_CONFIG_FILE), "utf8"),
4257
+ readFileSync12(join15(cwd, LINT_CONFIG_FILE), "utf8"),
4482
4258
  LINT_CONFIG_FILE
4483
4259
  );
4484
4260
  return Array.isArray(parsed.extends) ? parsed.extends.filter((entry) => typeof entry === "string") : [];
@@ -4489,7 +4265,7 @@ function committedExtends(cwd) {
4489
4265
  function committedIgnorePatterns(cwd) {
4490
4266
  try {
4491
4267
  const parsed = parseJsonc(
4492
- readFileSync15(join19(cwd, LINT_CONFIG_FILE), "utf8"),
4268
+ readFileSync12(join15(cwd, LINT_CONFIG_FILE), "utf8"),
4493
4269
  LINT_CONFIG_FILE
4494
4270
  );
4495
4271
  return Array.isArray(parsed.ignorePatterns) ? parsed.ignorePatterns.filter((entry) => typeof entry === "string") : [];
@@ -4501,7 +4277,7 @@ function moduleEslintDependencies(cwd) {
4501
4277
  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/");
4502
4278
  let manifest;
4503
4279
  try {
4504
- manifest = JSON.parse(readFileSync15(join19(cwd, "package.json"), "utf8"));
4280
+ manifest = JSON.parse(readFileSync12(join15(cwd, "package.json"), "utf8"));
4505
4281
  } catch {
4506
4282
  return [];
4507
4283
  }
@@ -4635,20 +4411,20 @@ ${result.stderr ?? ""}`);
4635
4411
  * build the developer can see, rather than a silent half-adoption they cannot.
4636
4412
  */
4637
4413
  writeModuleBaseline(ctx, oxlint, env) {
4638
- const configPath = join20(ctx.cwd, LINT_CONFIG_FILE);
4639
- const baselinePath = join20(ctx.cwd, LINT_BASELINE_FILE);
4640
- if (!existsSync16(configPath)) return;
4414
+ const configPath = join16(ctx.cwd, LINT_CONFIG_FILE);
4415
+ const baselinePath = join16(ctx.cwd, LINT_BASELINE_FILE);
4416
+ if (!existsSync12(configPath)) return;
4641
4417
  let config;
4642
4418
  try {
4643
4419
  config = parseJsonc(
4644
- readFileSync16(configPath, "utf8"),
4420
+ readFileSync13(configPath, "utf8"),
4645
4421
  LINT_CONFIG_FILE
4646
4422
  );
4647
4423
  } catch {
4648
4424
  return;
4649
4425
  }
4650
4426
  const current = Array.isArray(config.extends) ? config.extends : [];
4651
- const measurePath = join20(ctx.cwd, LINT_MEASURE_FILE);
4427
+ const measurePath = join16(ctx.cwd, LINT_MEASURE_FILE);
4652
4428
  let measured;
4653
4429
  try {
4654
4430
  writeFileSync2(
@@ -4696,7 +4472,7 @@ ${result.stderr ?? ""}`);
4696
4472
  }
4697
4473
  }
4698
4474
  async run(ctx) {
4699
- if (!existsSync16(join20(ctx.cwd, LINT_CONFIG_FILE))) {
4475
+ if (!existsSync12(join16(ctx.cwd, LINT_CONFIG_FILE))) {
4700
4476
  process.stderr.write(
4701
4477
  `sentinel lint(oxlint): no ${LINT_CONFIG_FILE} in this module; run \`sentinel --init --lint\` to adopt.
4702
4478
  `
@@ -4844,7 +4620,7 @@ ${result.stderr ?? ""}`);
4844
4620
  let stub;
4845
4621
  try {
4846
4622
  stub = parseJsonc(
4847
- readFileSync16(join20(cwd, LINT_CONFIG_FILE), "utf8"),
4623
+ readFileSync13(join16(cwd, LINT_CONFIG_FILE), "utf8"),
4848
4624
  LINT_CONFIG_FILE
4849
4625
  );
4850
4626
  } catch {
@@ -4854,7 +4630,7 @@ ${result.stderr ?? ""}`);
4854
4630
  for (const entry of stub.extends ?? []) {
4855
4631
  try {
4856
4632
  const preset = parseJsonc(
4857
- readFileSync16(join20(cwd, entry), "utf8"),
4633
+ readFileSync13(join16(cwd, entry), "utf8"),
4858
4634
  entry
4859
4635
  );
4860
4636
  for (const rule of Object.keys(preset.rules ?? {})) names.add(rule);
@@ -4924,14 +4700,14 @@ ${result.stderr ?? ""}`);
4924
4700
  let parsed;
4925
4701
  try {
4926
4702
  parsed = parseJsonc(
4927
- readFileSync16(join20(cwd, LINT_CONFIG_FILE), "utf8"),
4703
+ readFileSync13(join16(cwd, LINT_CONFIG_FILE), "utf8"),
4928
4704
  LINT_CONFIG_FILE
4929
4705
  );
4930
4706
  } catch {
4931
4707
  return void 0;
4932
4708
  }
4933
4709
  const targets = Array.isArray(parsed.extends) ? parsed.extends.filter((entry) => typeof entry === "string") : typeof parsed.extends === "string" ? [parsed.extends] : [];
4934
- return targets.find((target) => !existsSync16(join20(cwd, target)));
4710
+ return targets.find((target) => !existsSync12(join16(cwd, target)));
4935
4711
  }
4936
4712
  /** Announce what is not enforced, so reduced coverage is never silent. */
4937
4713
  announceDisabled(preset) {
@@ -4969,9 +4745,9 @@ function registerTest() {
4969
4745
 
4970
4746
  // src/roles/typescript/adapters/tsc/tsc.adapter.ts
4971
4747
  import { spawnSync as spawnSync5 } from "node:child_process";
4972
- import { existsSync as existsSync17, readFileSync as readFileSync18 } from "node:fs";
4973
- import { createRequire as createRequire2 } from "node:module";
4974
- import { join as join22 } from "node:path";
4748
+ import { existsSync as existsSync13, readFileSync as readFileSync15 } from "node:fs";
4749
+ import { createRequire } from "node:module";
4750
+ import { join as join18 } from "node:path";
4975
4751
 
4976
4752
  // src/roles/typescript/presets/nest.json
4977
4753
  var nest_default2 = {
@@ -5152,8 +4928,8 @@ function readTsconfigAdoption(cwd) {
5152
4928
  }
5153
4929
 
5154
4930
  // src/roles/typescript/adapters/tsc/plan.ts
5155
- import { readFileSync as readFileSync17 } from "node:fs";
5156
- import { join as join21 } from "node:path";
4931
+ import { readFileSync as readFileSync14 } from "node:fs";
4932
+ import { join as join17 } from "node:path";
5157
4933
 
5158
4934
  // src/roles/typescript/typecheck-script.ts
5159
4935
  var SENTINEL_TYPECHECK_COMMAND = "sentinel --run --typescript";
@@ -5246,7 +5022,7 @@ function planAdoption(context) {
5246
5022
  };
5247
5023
  }
5248
5024
  const existing = parseJsonc(
5249
- readFileSync17(join21(context.cwd, target.path), "utf8"),
5025
+ readFileSync14(join17(context.cwd, target.path), "utf8"),
5250
5026
  target.path
5251
5027
  );
5252
5028
  const extendsChain = composeExtends(existing.extends, preset);
@@ -5384,7 +5160,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5384
5160
  let chain;
5385
5161
  try {
5386
5162
  const parsed = parseJsonc(
5387
- readFileSync18(join22(cwd, target.path), "utf8"),
5163
+ readFileSync15(join18(cwd, target.path), "utf8"),
5388
5164
  target.path
5389
5165
  );
5390
5166
  chain = parsed.extends;
@@ -5397,7 +5173,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5397
5173
  );
5398
5174
  if (preset === void 0) return void 0;
5399
5175
  try {
5400
- createRequire2(join22(cwd, "noop.js")).resolve(preset);
5176
+ createRequire(join18(cwd, "noop.js")).resolve(preset);
5401
5177
  return void 0;
5402
5178
  } catch {
5403
5179
  return preset;
@@ -5493,7 +5269,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5493
5269
  referencedProjects(cwd, config) {
5494
5270
  try {
5495
5271
  const parsed = parseJsonc(
5496
- readFileSync18(join22(cwd, config), "utf8"),
5272
+ readFileSync15(join18(cwd, config), "utf8"),
5497
5273
  config
5498
5274
  );
5499
5275
  const referenced = (parsed.references ?? []).map((reference) => reference.path).filter((path) => typeof path === "string" && path.length > 0);
@@ -5509,7 +5285,7 @@ var TscAdapter = class _TscAdapter extends BaseAdapter {
5509
5285
  * check.
5510
5286
  */
5511
5287
  typecheckTarget(cwd) {
5512
- if (existsSync17(join22(cwd, "tsconfig.json"))) return "tsconfig.json";
5288
+ if (existsSync13(join18(cwd, "tsconfig.json"))) return "tsconfig.json";
5513
5289
  const target = resolveTsconfigTarget(cwd);
5514
5290
  return target.reason === "none" ? null : target.path;
5515
5291
  }
@@ -5714,7 +5490,7 @@ function replaceLines(current, replacements) {
5714
5490
  }
5715
5491
 
5716
5492
  // src/core/apply-plan.ts
5717
- import { existsSync as existsSync18, readFileSync as readFileSync19, renameSync, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
5493
+ import { existsSync as existsSync14, readFileSync as readFileSync16, renameSync, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
5718
5494
  import { resolve as resolve3, sep } from "node:path";
5719
5495
  import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
5720
5496
 
@@ -5733,7 +5509,7 @@ function resolveWithinRoot(cwd, relativePath) {
5733
5509
  return absolutePath;
5734
5510
  }
5735
5511
  function readIfExists(absolutePath) {
5736
- return existsSync18(absolutePath) ? readFileSync19(absolutePath, "utf8") : void 0;
5512
+ return existsSync14(absolutePath) ? readFileSync16(absolutePath, "utf8") : void 0;
5737
5513
  }
5738
5514
  function* leaves(value, prefix = []) {
5739
5515
  for (const [key, keyValue] of Object.entries(value)) {
@@ -5851,8 +5627,8 @@ function applyPlan(cwd, plan3) {
5851
5627
  }
5852
5628
 
5853
5629
  // src/core/config/preset-evidence.ts
5854
- import { existsSync as existsSync19, readdirSync as readdirSync3, readFileSync as readFileSync20 } from "node:fs";
5855
- import { join as join23 } from "node:path";
5630
+ import { existsSync as existsSync15, readdirSync as readdirSync3, readFileSync as readFileSync17 } from "node:fs";
5631
+ import { join as join19 } from "node:path";
5856
5632
  var PATH_SIGNALS = [
5857
5633
  {
5858
5634
  preset: "nest",
@@ -5886,12 +5662,12 @@ function sourceImports(cwd, pattern) {
5886
5662
  }
5887
5663
  for (const entry of entries) {
5888
5664
  if (entry.isDirectory()) {
5889
- if (!NOT_SCANNED.has(entry.name)) stack.push(join23(dir, entry.name));
5665
+ if (!NOT_SCANNED.has(entry.name)) stack.push(join19(dir, entry.name));
5890
5666
  continue;
5891
5667
  }
5892
5668
  if (!SOURCE_FILE.test(entry.name)) continue;
5893
5669
  try {
5894
- if (pattern.test(readFileSync20(join23(dir, entry.name), "utf8"))) return true;
5670
+ if (pattern.test(readFileSync17(join19(dir, entry.name), "utf8"))) return true;
5895
5671
  } catch {
5896
5672
  continue;
5897
5673
  }
@@ -5900,11 +5676,11 @@ function sourceImports(cwd, pattern) {
5900
5676
  return false;
5901
5677
  }
5902
5678
  function dependencyNames(cwd) {
5903
- const path = join23(cwd, "package.json");
5904
- if (!existsSync19(path)) return [];
5679
+ const path = join19(cwd, "package.json");
5680
+ if (!existsSync15(path)) return [];
5905
5681
  try {
5906
5682
  const manifest = parseJsonc(
5907
- readFileSync20(path, "utf8"),
5683
+ readFileSync17(path, "utf8"),
5908
5684
  path
5909
5685
  );
5910
5686
  return [
@@ -5927,7 +5703,7 @@ function declaresJsx(cwd) {
5927
5703
  for (const name of entries) {
5928
5704
  try {
5929
5705
  const config = parseJsonc(
5930
- readFileSync20(join23(cwd, name), "utf8"),
5706
+ readFileSync17(join19(cwd, name), "utf8"),
5931
5707
  name
5932
5708
  );
5933
5709
  if (config.compilerOptions?.jsx !== void 0) return true;
@@ -4705,7 +4705,7 @@ function planJestMigration(context, adoption) {
4705
4705
  const formatter = writtenFileFormatter(context.cwd);
4706
4706
  if (formatter !== void 0) {
4707
4707
  notes.push(
4708
- `the files this rewrote were run through ${formatter.origin} afterwards. Two things needed it: the Vitest type imports land beside the existing ones, and a rewritten call is often SHORTER than the one it replaced, so the line breaks around it are no longer the ones your config would pick. Neither is a defect, and both would have failed your format check. What no formatter does is ORDER imports, so if your lint enforces \`import/order\`, run its fixer too.`
4708
+ `the files this rewrote were run through ${formatter.origin} afterwards. Two things needed it: the Vitest type imports land beside the existing ones, and a rewritten call is often SHORTER than the one it replaced, so the line breaks around it are no longer the ones your config would pick. Neither is a defect, and both would have failed your format check. Ordering those imports is a LINT rule and no formatter does it, so this run also puts your own fixer over the same files: measured across the whole repository, \`import/order\` alone would otherwise have put 19 modules in the red.`
4709
4709
  );
4710
4710
  } else {
4711
4711
  notes.push(
@@ -4864,6 +4864,285 @@ function plan(context) {
4864
4864
  return { operations, notes };
4865
4865
  }
4866
4866
 
4867
+ // src/roles/test/written-file-linter.ts
4868
+ import { existsSync as existsSync23 } from "node:fs";
4869
+ import { join as join29 } from "node:path";
4870
+
4871
+ // src/roles/lint/module-baseline.ts
4872
+ import { existsSync as existsSync19, readFileSync as readFileSync20 } from "node:fs";
4873
+ import { join as join25 } from "node:path";
4874
+ var LINT_BASELINE_FILE = ".oxlintrc.baseline.json";
4875
+ var LINT_MEASURE_FILE = ".oxlintrc.measure.json";
4876
+ var LINT_BASELINE_SPECIFIER = `./${LINT_BASELINE_FILE}`;
4877
+ function holdsFrom(errorCounts) {
4878
+ return [...errorCounts].map(([rule, count]) => ({ rule, count })).sort((left, right) => right.count - left.count || left.rule.localeCompare(right.rule));
4879
+ }
4880
+ function renderBaseline(holds, version) {
4881
+ const entries = holds.map(({ rule }) => ` ${JSON.stringify(rule)}: "warn"`).join(",\n");
4882
+ return [
4883
+ "{",
4884
+ ` // Written by @hublo/sentinel@${version}. Do not edit by hand: \`sentinel --init --lint\``,
4885
+ " // regenerates it from a fresh measurement, and a rule you have since fixed drops out",
4886
+ " // and returns to `error` on its own.",
4887
+ " //",
4888
+ " // These rules are held at `warn` because this module ALREADY violated them when it",
4889
+ " // adopted. They still run and still report, so the build cannot fail on them.",
4890
+ " //",
4891
+ " // The hold is per RULE, not per occurrence: oxlint cannot freeze a specific list of",
4892
+ " // violations, so a NEW violation of one of these rules is covered too, and will only",
4893
+ " // warn. That is the cost of adopting without a red build, and the reason to clear this",
4894
+ " // file rather than live with it.",
4895
+ " //",
4896
+ " // `sentinel --inspect --lint` lists what is held; `sentinel --run --lint --json` counts",
4897
+ " // what is left, per rule. Fix them and re-run `--init --lint` to get the severity back.",
4898
+ ' "rules": {',
4899
+ entries,
4900
+ " }",
4901
+ "}",
4902
+ ""
4903
+ ].join("\n");
4904
+ }
4905
+ function extendsWithBaseline(current, hasHolds) {
4906
+ const withoutBaseline = current.filter((entry) => entry !== LINT_BASELINE_SPECIFIER);
4907
+ return hasHolds ? [...withoutBaseline, LINT_BASELINE_SPECIFIER] : withoutBaseline;
4908
+ }
4909
+ function describeHolds(holds) {
4910
+ if (holds.length === 0) return "";
4911
+ const total = holds.reduce((sum, hold) => sum + hold.count, 0);
4912
+ const listed = holds.slice(0, 5).map(({ rule, count }) => `${rule} (${count})`).join(", ");
4913
+ const rest = holds.length > 5 ? `, and ${holds.length - 5} more` : "";
4914
+ 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`;
4915
+ }
4916
+ function heldRules(cwd) {
4917
+ const path = join25(cwd, LINT_BASELINE_FILE);
4918
+ if (!existsSync19(path)) return [];
4919
+ try {
4920
+ const parsed = parseJsonc(
4921
+ readFileSync20(path, "utf8"),
4922
+ LINT_BASELINE_FILE
4923
+ );
4924
+ return Object.keys(parsed.rules ?? {});
4925
+ } catch {
4926
+ return [];
4927
+ }
4928
+ }
4929
+
4930
+ // src/roles/lint/config-policy.ts
4931
+ var LINT_CONFIG_FILE = ".oxlintrc.json";
4932
+ var LINT_SCRIPT_NAME = "lint";
4933
+ var PRESET_DIR = "./node_modules/@hublo/sentinel/lint";
4934
+ function presetPath(preset) {
4935
+ return `${PRESET_DIR}/${preset}.json`;
4936
+ }
4937
+ function isSentinelPreset(entry) {
4938
+ return /@hublo\/sentinel\/(lint|oxlint)\//.test(entry);
4939
+ }
4940
+ function extendsWithPreset(current, variant) {
4941
+ const own = presetPath(variant);
4942
+ const others = [...new Set(current.filter((entry) => entry !== own && !isSentinelPreset(entry)))];
4943
+ const baseline = others.filter((entry) => entry === LINT_BASELINE_SPECIFIER);
4944
+ const rest = others.filter((entry) => entry !== LINT_BASELINE_SPECIFIER);
4945
+ return [own, ...rest, ...baseline];
4946
+ }
4947
+ function presetVariant(preset, cwd) {
4948
+ const normalised = cwd.replaceAll("\\", "/");
4949
+ return preset === "react" && /(^|\/)libs\//.test(normalised) ? "react-lib" : preset;
4950
+ }
4951
+ function presetOfVariant(variant) {
4952
+ return variant === "react-lib" ? "react" : variant;
4953
+ }
4954
+ var SENTINEL_LINT_PRESET = /@hublo\/sentinel\/(?:lint|oxlint)\/([a-z-]+)\.json$/;
4955
+ function presetNameFromPath(preset) {
4956
+ return preset ? SENTINEL_LINT_PRESET.exec(preset)?.[1] ?? void 0 : void 0;
4957
+ }
4958
+ var PERMITTED_LOCAL_KEYS2 = ["extends", "ignorePatterns"];
4959
+ var ESLINT_CONFIG_FILES = [
4960
+ "eslint.config.js",
4961
+ "eslint.config.mjs",
4962
+ "eslint.config.cjs",
4963
+ "eslint.config.ts"
4964
+ ];
4965
+ function lintTarget() {
4966
+ return { cache: true, inputs: ["default", "^default", `{projectRoot}/${LINT_CONFIG_FILE}`] };
4967
+ }
4968
+
4969
+ // src/core/config/read-adoption.ts
4970
+ import { existsSync as existsSync21, readFileSync as readFileSync22 } from "node:fs";
4971
+ import { join as join27 } from "node:path";
4972
+
4973
+ // src/core/config/owned-keys.ts
4974
+ function presetOwnedKeys(config, permitted, presetSets) {
4975
+ if (!config) return [];
4976
+ return Object.keys(config).filter(
4977
+ (key) => !permitted.includes(key) && (presetSets === void 0 || presetSets.includes(key))
4978
+ );
4979
+ }
4980
+ function localOnlyKeys(config, permitted, presetSets) {
4981
+ if (!config) return [];
4982
+ return Object.keys(config).filter((key) => !permitted.includes(key) && !presetSets.includes(key));
4983
+ }
4984
+
4985
+ // src/core/config/resolve-config-target.ts
4986
+ import { existsSync as existsSync20, readFileSync as readFileSync21 } from "node:fs";
4987
+ import { join as join26 } from "node:path";
4988
+ function readExtends(absolutePath) {
4989
+ let parsed;
4990
+ try {
4991
+ parsed = parseJsonc(readFileSync21(absolutePath, "utf8"), absolutePath);
4992
+ } catch {
4993
+ return [];
4994
+ }
4995
+ if (typeof parsed.extends === "string") return [parsed.extends];
4996
+ if (Array.isArray(parsed.extends)) {
4997
+ return parsed.extends.filter((entry) => typeof entry === "string");
4998
+ }
4999
+ return [];
5000
+ }
5001
+ function resolveConfigTarget(moduleDir, { candidates, markers, fallback }) {
5002
+ let existing;
5003
+ let existingExtendsSomething = false;
5004
+ for (const candidate of candidates) {
5005
+ const absolutePath = join26(moduleDir, candidate);
5006
+ if (!existsSync20(absolutePath)) continue;
5007
+ const chain = readExtends(absolutePath);
5008
+ if (existing === void 0) {
5009
+ existing = candidate;
5010
+ existingExtendsSomething = chain.length > 0;
5011
+ }
5012
+ const extendsBase = chain.some((value) => markers.some((marker) => value.includes(marker)));
5013
+ if (extendsBase) return { path: candidate, reason: "extends-base" };
5014
+ }
5015
+ if (existing) {
5016
+ return { path: existing, reason: existingExtendsSomething ? "other-chain" : "no-extends" };
5017
+ }
5018
+ return { path: fallback, reason: "none" };
5019
+ }
5020
+
5021
+ // src/core/config/read-adoption.ts
5022
+ function normaliseExtends(value) {
5023
+ if (typeof value === "string") return [value];
5024
+ if (Array.isArray(value)) {
5025
+ return value.filter((entry) => typeof entry === "string");
5026
+ }
5027
+ return [];
5028
+ }
5029
+ var NOT_ADOPTED2 = (configFile, unreadable = null) => ({
5030
+ configFile,
5031
+ preset: null,
5032
+ adopted: false,
5033
+ conformant: false,
5034
+ drift: [],
5035
+ unreadable
5036
+ });
5037
+ function readAdoption(cwd, options) {
5038
+ const target = resolveConfigTarget(cwd, options);
5039
+ if (target.reason === "none" || !existsSync21(join27(cwd, target.path))) {
5040
+ return NOT_ADOPTED2(target.reason === "none" ? null : target.path);
5041
+ }
5042
+ let parsed;
5043
+ try {
5044
+ parsed = parseJsonc(readFileSync22(join27(cwd, target.path), "utf8"), target.path);
5045
+ } catch (error) {
5046
+ const reason = error instanceof Error ? error.message : String(error);
5047
+ return NOT_ADOPTED2(target.path, `${target.path} could not be parsed (${reason})`);
5048
+ }
5049
+ const preset = normaliseExtends(parsed.extends).find((entry) => options.presetPattern.test(entry)) ?? null;
5050
+ if (preset === null) return NOT_ADOPTED2(target.path);
5051
+ const settings = options.settingsKey === null ? parsed : parsed[options.settingsKey];
5052
+ const drift = presetOwnedKeys(settings, options.permitted, options.presetOwns?.(preset));
5053
+ return {
5054
+ configFile: target.path,
5055
+ preset,
5056
+ adopted: true,
5057
+ conformant: drift.length === 0,
5058
+ drift,
5059
+ unreadable: null
5060
+ };
5061
+ }
5062
+
5063
+ // src/roles/lint/read-adoption.ts
5064
+ function readLintAdoption(cwd) {
5065
+ return readAdoption(cwd, {
5066
+ candidates: [LINT_CONFIG_FILE],
5067
+ markers: ["@hublo/sentinel/oxlint/"],
5068
+ fallback: LINT_CONFIG_FILE,
5069
+ presetPattern: SENTINEL_LINT_PRESET,
5070
+ // Drift for lint is any TOP-LEVEL key beyond `extends`, so the settings the shared
5071
+ // reader inspects are the config's own root rather than a nested options object.
5072
+ settingsKey: null,
5073
+ permitted: PERMITTED_LOCAL_KEYS2
5074
+ });
5075
+ }
5076
+
5077
+ // src/roles/lint/resolve-oxlint.ts
5078
+ import { existsSync as existsSync22 } from "node:fs";
5079
+ import { createRequire as createRequire6 } from "node:module";
5080
+ import { delimiter as delimiter2, dirname as dirname12, join as join28 } from "node:path";
5081
+ import { fileURLToPath } from "node:url";
5082
+ var PACKAGE_OF = {
5083
+ oxlint: "oxlint",
5084
+ tsgolint: "oxlint-tsgolint"
5085
+ };
5086
+ var require3 = createRequire6(import.meta.url);
5087
+ function resolveOxlint(cwd, name = "oxlint") {
5088
+ return resolveBin(cwd, name) ?? binFromOwnInstall(PACKAGE_OF[name], name);
5089
+ }
5090
+ function canRunTypeAware(cwd) {
5091
+ return tsgolintShim(cwd) !== void 0;
5092
+ }
5093
+ function oxlintPath(cwd, env) {
5094
+ const shim = tsgolintShim(cwd);
5095
+ if (!shim) return env.PATH;
5096
+ return [dirname12(shim), env.PATH].filter(Boolean).join(delimiter2);
5097
+ }
5098
+ function tsgolintShim(cwd) {
5099
+ const fromModule = resolveBin(cwd, "tsgolint");
5100
+ if (fromModule) return fromModule;
5101
+ const candidates = [];
5102
+ for (const owner of ["oxlint-tsgolint", "oxlint"]) {
5103
+ try {
5104
+ const packageDir = dirname12(require3.resolve(`${owner}/package.json`));
5105
+ candidates.push(join28(packageDir, "node_modules", ".bin", "tsgolint"));
5106
+ candidates.push(join28(packageDir, "..", ".bin", "tsgolint"));
5107
+ } catch {
5108
+ }
5109
+ }
5110
+ try {
5111
+ const ownRoot = dirname12(require3.resolve("@hublo/sentinel/package.json"));
5112
+ candidates.push(join28(ownRoot, "node_modules", ".bin", "tsgolint"));
5113
+ } catch {
5114
+ candidates.push(resolveBin(dirname12(fileURLToPath(import.meta.url)), "tsgolint") ?? "");
5115
+ }
5116
+ return candidates.find((candidate) => candidate !== "" && existsSync22(candidate));
5117
+ }
5118
+ function oxlintSearchPath(cwd) {
5119
+ return binSearchPath(cwd);
5120
+ }
5121
+
5122
+ // src/roles/test/written-file-linter.ts
5123
+ function writtenFileLinter(cwd) {
5124
+ if (readLintAdoption(cwd).configFile !== null) {
5125
+ const oxlint = resolveOxlint(cwd);
5126
+ if (oxlint !== void 0) {
5127
+ return { bin: oxlint, args: ["--fix"], origin: "this module's own oxlint config" };
5128
+ }
5129
+ }
5130
+ const config = ESLINT_CONFIG_FILES.map((name) => join29(cwd, name)).find((path) => existsSync23(path));
5131
+ const eslint = resolveBin(cwd, "eslint");
5132
+ if (config !== void 0 && eslint !== void 0) {
5133
+ return {
5134
+ bin: eslint,
5135
+ /*
5136
+ * `--no-error-on-unmatched-pattern` because the batch is a list of files this run wrote, and
5137
+ * one of them may sit under an ignore. That is not an error worth a message.
5138
+ */
5139
+ args: ["--fix", "--no-error-on-unmatched-pattern", "-c", config],
5140
+ origin: "this module's own ESLint"
5141
+ };
5142
+ }
5143
+ return void 0;
5144
+ }
5145
+
4867
5146
  // src/roles/test/adapters/vitest/vitest.adapter.ts
4868
5147
  var FORMATTABLE = /\.(m|c)?(j|t)sx?$|\.json$/;
4869
5148
  var FORMAT_BATCH = 25;
@@ -4945,6 +5224,7 @@ var VitestAdapter = class extends BaseAdapter {
4945
5224
  async afterInit(ctx) {
4946
5225
  const written = (ctx.written ?? []).filter((file) => FORMATTABLE.test(file));
4947
5226
  if (written.length === 0) return Promise.resolve();
5227
+ this.fixWithTheModulesLinter(ctx, written);
4948
5228
  const formatter = writtenFileFormatter(ctx.cwd);
4949
5229
  if (formatter === void 0) {
4950
5230
  this.say(
@@ -4970,6 +5250,35 @@ var VitestAdapter = class extends BaseAdapter {
4970
5250
  );
4971
5251
  return Promise.resolve();
4972
5252
  }
5253
+ /**
5254
+ * The module's own fixer, over the files this run wrote, BEFORE the formatter.
5255
+ *
5256
+ * Measured across all 107 modules of the monorepo: of 1373 lint errors the migration caused,
5257
+ * 1369 were `import/order`. The migration adds an `import type ... from 'vitest'` beside the
5258
+ * imports a file already has, and no formatter moves it, because ordering imports is a lint rule
5259
+ * and not a style. Nineteen modules went red on a target the migration never touched.
5260
+ *
5261
+ * Never fatal, for the same reason the formatter is not: adoption succeeded, and reverting a
5262
+ * migration over a tidy-up would be the wrong trade. A non-zero exit here usually means problems
5263
+ * the fixer could not fix, which belong to the module and not to this run, so it is reported as
5264
+ * what it is rather than as a failure.
5265
+ */
5266
+ fixWithTheModulesLinter(ctx, written) {
5267
+ const linter = writtenFileLinter(ctx.cwd);
5268
+ if (linter === void 0) return;
5269
+ let unfixed = 0;
5270
+ for (let at = 0; at < written.length; at += FORMAT_BATCH) {
5271
+ const batch = written.slice(at, at + FORMAT_BATCH);
5272
+ const pass = spawnSync(linter.bin, [...linter.args, ...batch], {
5273
+ cwd: ctx.cwd,
5274
+ encoding: "utf8"
5275
+ });
5276
+ if (pass.status !== 0) unfixed += batch.length;
5277
+ }
5278
+ this.say(
5279
+ unfixed === 0 ? `ran ${linter.origin} with its fixer over the ${written.length} file(s) this wrote, so the import this migration adds is ordered the way your rules ask. Without it, \`import/order\` alone put 19 modules of this repository in the red.` : `${linter.origin} still reports problems on ${unfixed} of the ${written.length} file(s) this wrote, after its fixer ran. Adoption itself is applied; those are rules its fixer cannot settle on its own, so read them before committing.`
5280
+ );
5281
+ }
4973
5282
  plan(context) {
4974
5283
  return plan(context);
4975
5284
  }
@@ -5085,6 +5394,33 @@ export {
5085
5394
  rewriteToolchainImports,
5086
5395
  resolveOxfmt,
5087
5396
  oxfmtRejects,
5397
+ LINT_BASELINE_FILE,
5398
+ LINT_MEASURE_FILE,
5399
+ LINT_BASELINE_SPECIFIER,
5400
+ holdsFrom,
5401
+ renderBaseline,
5402
+ extendsWithBaseline,
5403
+ describeHolds,
5404
+ heldRules,
5405
+ LINT_CONFIG_FILE,
5406
+ LINT_SCRIPT_NAME,
5407
+ presetPath,
5408
+ isSentinelPreset,
5409
+ extendsWithPreset,
5410
+ presetVariant,
5411
+ presetOfVariant,
5412
+ presetNameFromPath,
5413
+ ESLINT_CONFIG_FILES,
5414
+ lintTarget,
5415
+ presetOwnedKeys,
5416
+ localOnlyKeys,
5417
+ resolveConfigTarget,
5418
+ readAdoption,
5419
+ readLintAdoption,
5420
+ resolveOxlint,
5421
+ canRunTypeAware,
5422
+ oxlintPath,
5423
+ oxlintSearchPath,
5088
5424
  walk,
5089
5425
  readTestAdoption,
5090
5426
  resolveContext,
package/dist/index.js CHANGED
@@ -6,10 +6,10 @@ import {
6
6
  registerAdapters,
7
7
  resolve,
8
8
  setDefaultRunner
9
- } from "./chunk-ZMUTKBZ2.js";
9
+ } from "./chunk-2N5D63HC.js";
10
10
  import {
11
11
  BaseAdapter
12
- } from "./chunk-OORAEYSU.js";
12
+ } from "./chunk-YMSI5ZCB.js";
13
13
  import "./chunk-O7REVMOC.js";
14
14
  import "./chunk-QXFCZON7.js";
15
15
  import "./chunk-NW6UHNYX.js";
@@ -7,7 +7,7 @@ import {
7
7
  readTestAdoption,
8
8
  resolveVitest,
9
9
  suiteOf
10
- } from "./chunk-OORAEYSU.js";
10
+ } from "./chunk-YMSI5ZCB.js";
11
11
  import "./chunk-O7REVMOC.js";
12
12
  import "./chunk-QXFCZON7.js";
13
13
  import "./chunk-NW6UHNYX.js";
@@ -17,19 +17,23 @@ import { execFileSync } from "node:child_process";
17
17
  import { existsSync, mkdtempSync, readFileSync } from "node:fs";
18
18
  import { tmpdir } from "node:os";
19
19
  import { join } from "node:path";
20
- function runVitest(cwd, config) {
20
+ function runVitest(cwd, config, extra = []) {
21
21
  const { bin } = resolveVitest(cwd);
22
22
  if (bin === void 0) return void 0;
23
23
  const relocated = fromWorkspaceRoot(cwd, ["--config", config], []);
24
24
  const out = join(mkdtempSync(join(tmpdir(), "sentinel-validate-")), "vitest.json");
25
25
  try {
26
- execFileSync(bin, ["run", ...relocated.options, "--reporter=json", `--outputFile=${out}`], {
27
- cwd: relocated.cwd,
28
- // The SAME environment the reference was taken in, or the two sides are two programs.
29
- env: moduleEnvironment(cwd).env,
30
- stdio: ["ignore", "ignore", "ignore"],
31
- maxBuffer: 1 << 28
32
- });
26
+ execFileSync(
27
+ bin,
28
+ ["run", ...relocated.options, ...extra, "--reporter=json", `--outputFile=${out}`],
29
+ {
30
+ cwd: relocated.cwd,
31
+ // The SAME environment the reference was taken in, or the two sides are two programs.
32
+ env: moduleEnvironment(cwd).env,
33
+ stdio: ["ignore", "ignore", "ignore"],
34
+ maxBuffer: 1 << 28
35
+ }
36
+ );
33
37
  } catch {
34
38
  }
35
39
  if (!existsSync(out)) return void 0;
@@ -48,6 +52,31 @@ function vitestConfigFor(cwd, suite) {
48
52
  }
49
53
  return void 0;
50
54
  }
55
+ function sharesAWorker(cwd, config) {
56
+ try {
57
+ return /\bisolate\s*:\s*false\b/.test(readFileSync(join(cwd, config), "utf8"));
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+ function shuffledPassMatches(root, suite, config) {
63
+ const seed = Math.floor(Math.random() * 1e6);
64
+ const report = runVitest(root, config, ["--sequence.shuffle.files", `--sequence.seed=${seed}`]);
65
+ if (report === void 0) {
66
+ return {
67
+ ok: false,
68
+ message: `the shuffled pass (seed ${seed}) produced no report, so order independence is unproven.`
69
+ };
70
+ }
71
+ const comparison = compareAgainstReference(root, suite, report);
72
+ if (comparison.ok) {
73
+ return { ok: true, message: `and again in a random file order (seed ${seed}).` };
74
+ }
75
+ return {
76
+ ok: false,
77
+ message: `this suite shares a worker, and it depends on the ORDER of its files: in a random order (seed ${seed}) it no longer matches. ${comparison.message} Reproduce with \`--sequence.shuffle.files --sequence.seed=${seed}\`. Something a file does outlives it; until that is found, the suite is not safe without isolation.`
78
+ };
79
+ }
51
80
  var KNOWN_SUITES = ["main", "prisma", "integration", "functional"];
52
81
  function suitesOf(cwd, adoption) {
53
82
  const declared = adoption.state === "jest" ? adoption.jestConfigs.map(suiteOf) : KNOWN_SUITES.filter((suite) => vitestConfigFor(cwd, suite) !== void 0);
@@ -103,7 +132,16 @@ function validateSuite(name, root, adoption, suite) {
103
132
  };
104
133
  }
105
134
  const comparison = compareAgainstReference(root, suite, report);
106
- return { module: name, suite, ok: comparison.ok, message: comparison.message };
135
+ if (!comparison.ok || !sharesAWorker(root, config2)) {
136
+ return { module: name, suite, ok: comparison.ok, message: comparison.message };
137
+ }
138
+ const shuffled = shuffledPassMatches(root, suite, config2);
139
+ return {
140
+ module: name,
141
+ suite,
142
+ ok: shuffled.ok,
143
+ message: shuffled.ok ? `${comparison.message} ${shuffled.message}` : shuffled.message
144
+ };
107
145
  }
108
146
  if (adoption.state !== "jest") {
109
147
  return {
@@ -126,5 +164,6 @@ function validateSuite(name, root, adoption, suite) {
126
164
  return { module: name, suite, ok: capture.captured, message: capture.message };
127
165
  }
128
166
  export {
167
+ sharesAWorker,
129
168
  validateModuleTests
130
169
  };
@@ -290,6 +290,103 @@ as `Mocked<typeof import(...)>` is worse: it demands the real return type and tu
290
290
  `Record<string, Mock>` describes what the factory on the line above actually built. It gives up the
291
291
  member NAMES, which `any` gave up too. Applied only when the SAME file mocks that specifier.
292
292
 
293
+ ## Going faster than jest ever was, if your module can
294
+
295
+ Vitest gives every test file its own module registry and its own environment, so nothing one file
296
+ does can reach the next. That is what makes a run expensive: the whole import graph is re-evaluated
297
+ once per file. Measured on `libs/front/components`, 230 files and 1418 tests, same machine and
298
+ session:
299
+
300
+ | | wall |
301
+ | ---------------------------------- | ---------- |
302
+ | jest, files in parallel | 15.9 s |
303
+ | vitest, isolated (the default) | 44.6 s |
304
+ | **vitest, files sharing a worker** | **13.0 s** |
305
+
306
+ On a real CI agent, same module: jest 158.7 s, isolated vitest 352.5 s, shared worker **92.7 s**.
307
+
308
+ You ask for it with one line:
309
+
310
+ ```ts
311
+ reactTestConfig({
312
+ root: import.meta.dirname,
313
+ workspaceRoot: path.resolve(import.meta.dirname, '../../..'),
314
+ isolate: false,
315
+ overrides: { ... },
316
+ })
317
+ ```
318
+
319
+ ### What sentinel does so that this is safe
320
+
321
+ Two things travel with the option, and neither is optional.
322
+
323
+ **A file boundary.** A setup file is re-executed for every test file even when the registry is
324
+ shared, so `afterAll` in a setup file IS the end of a file. Sentinel puts one there and gives back,
325
+ per file, what isolation used to reset for free: Testing Library's cleanup (it registers itself at
326
+ IMPORT time, so without this it arms for one file and 342 of 1418 tests fail with
327
+ `Found multiple elements`), an empty `document`, every spy, real timers, and the file's own mock
328
+ register.
329
+
330
+ That last one is worth knowing about. Measured on plain vitest, `vi.resetAllMocks()` DESTROYS a
331
+ mock's implementation while `clearAllMocks` and `restoreAllMocks` keep it. Under jest that could not
332
+ outlive the file. Sharing a worker it reaches every later file, so one `vi.resetAllMocks()` in one
333
+ hook leaves a shared fixture returning `undefined` for the rest of the run. Sentinel scopes those
334
+ three calls back to the file, and never touches what a setup file built.
335
+
336
+ **A split.** `vi.mock` has to be in place before the module it replaces is imported, and a module
337
+ already evaluated cannot be un-evaluated. So the files that declare a module mock keep their
338
+ isolation, in a second vitest project, and the rest share. The list is read from your sources on
339
+ every run, so adding a test never puts it in the wrong project. On `libs/front/components` that is
340
+ 13 files of 230.
341
+
342
+ ### What you may have to change
343
+
344
+ **One line, if your suite closes msw itself.** `server.close()` disposes the interceptors msw
345
+ installed and leaves a dead registry behind, so the next `listen()` finds it and interception
346
+ silently stops. Its boundary is the worker, not the file. If your setup has
347
+ `afterAll(() => server.close())`, remove it; if you use `@hublo/sentinel/test/setup/msw-lifecycle`,
348
+ this is already handled.
349
+
350
+ **Anything else `--validate` names.** A mutable singleton at module scope in product code, a test
351
+ writing to `globalThis` without cleaning up: these were forgiven by isolation and are not any more.
352
+
353
+ ### Nest does not get this, and the refusal says why
354
+
355
+ Measured on `apps/nest/microservices/agency`: 43 s to 40 s, seven percent, where a jsdom module
356
+ goes from 44.6 s to 13 s. A Nest suite's cost is its test bodies; a React suite's is re-evaluating
357
+ its import graph. Against that seven percent, Nest registers its metadata as an import SIDE EFFECT,
358
+ so a shared registry lets one suite see what another registered. The flavour refuses the option
359
+ rather than ignoring it.
360
+
361
+ ### The order is the proof, and `--validate` takes it
362
+
363
+ Isolation's whole product is that the order of your files cannot change the result. So when your
364
+ config says `isolate: false`, `--validate` runs the suite a SECOND time in a random file order and
365
+ compares that run against the same reference. One call, both claims: nothing was lost, and nothing
366
+ depends on the order.
367
+
368
+ This is not ceremony. Three runs of an identical configuration once gave three different failure
369
+ lists, because vitest's default sequencer puts previously-failed files first, and each new order
370
+ exposed a different leak. A single green run says very little.
371
+
372
+ ```console
373
+ ✗ front-components (main) — this suite shares a worker, and it depends on the ORDER of its files:
374
+ in a random order (seed 418293) it no longer matches. ... Reproduce with
375
+ `--sequence.shuffle.files --sequence.seed=418293`. Something a file does outlives it; until that
376
+ is found, the suite is not safe without isolation.
377
+ ```
378
+
379
+ ### Whether it is worth it for your module
380
+
381
+ Ask how many of your test files declare a module mock, since those keep their isolation:
382
+
383
+ ```console
384
+ $ rg -l 'vi\.mock\(|vi\.doMock\(' src --glob '*.{test,spec}.{ts,tsx}' | wc -l
385
+ ```
386
+
387
+ `libs/front/components` has 13 of 230 and gains 3.4x. `apps/front/host-admin` has 603 of 1463, so
388
+ most of its files would keep isolating and most of the cost would stay.
389
+
293
390
  ## The check that decides whether you are done
294
391
 
295
392
  Not "the suite is green". **The suite runs the same tests as before**. You do not have to arrange
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hublo/sentinel",
3
- "version": "1.4.0-alpha.36",
3
+ "version": "1.4.0-alpha.37",
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",