@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.
- package/dist/bin/sentinel.js +3 -3
- package/dist/{chunk-ZMUTKBZ2.js → chunk-2N5D63HC.js} +79 -303
- package/dist/{chunk-OORAEYSU.js → chunk-YMSI5ZCB.js} +337 -1
- package/dist/index.js +2 -2
- package/dist/{validate-2RTPM5EB.js → validate-MTAEHEMX.js} +49 -10
- package/docs/test-adoption.md +97 -0
- package/package.json +1 -1
package/dist/bin/sentinel.js
CHANGED
|
@@ -15,10 +15,10 @@ import {
|
|
|
15
15
|
palette,
|
|
16
16
|
registerAdapters,
|
|
17
17
|
resolve
|
|
18
|
-
} from "../chunk-
|
|
18
|
+
} from "../chunk-2N5D63HC.js";
|
|
19
19
|
import {
|
|
20
20
|
resolveContext
|
|
21
|
-
} from "../chunk-
|
|
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-
|
|
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-
|
|
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
|
|
1901
|
-
import { join as
|
|
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
|
|
3837
|
-
import { isAbsolute, join as
|
|
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 (!
|
|
3769
|
+
if (!existsSync9(path)) return void 0;
|
|
3841
3770
|
try {
|
|
3842
3771
|
const parsed = parseJsonc(
|
|
3843
|
-
|
|
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
|
-
|
|
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
|
|
4076
|
-
import { join as
|
|
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
|
|
4080
|
-
import { join as
|
|
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) =>
|
|
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 =
|
|
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
|
|
4188
|
-
import { join as
|
|
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 =
|
|
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 =
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
4639
|
-
const baselinePath =
|
|
4640
|
-
if (!
|
|
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
|
-
|
|
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 =
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) => !
|
|
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
|
|
4973
|
-
import { createRequire
|
|
4974
|
-
import { join as
|
|
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
|
|
5156
|
-
import { join as
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
5855
|
-
import { join as
|
|
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(
|
|
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(
|
|
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 =
|
|
5904
|
-
if (!
|
|
5679
|
+
const path = join19(cwd, "package.json");
|
|
5680
|
+
if (!existsSync15(path)) return [];
|
|
5905
5681
|
try {
|
|
5906
5682
|
const manifest = parseJsonc(
|
|
5907
|
-
|
|
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
|
-
|
|
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.
|
|
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-
|
|
9
|
+
} from "./chunk-2N5D63HC.js";
|
|
10
10
|
import {
|
|
11
11
|
BaseAdapter
|
|
12
|
-
} from "./chunk-
|
|
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-
|
|
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(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
};
|
package/docs/test-adoption.md
CHANGED
|
@@ -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.
|
|
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",
|