@avi2dg/checks 0.35.1 → 0.37.0

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.
@@ -0,0 +1,209 @@
1
+ type Key = string | number;
2
+
3
+ type Entry = { readonly key: Key; readonly start: number; readonly node: Tree; readonly comma: number | undefined };
4
+
5
+ type Container = { readonly kind: "object" | "array"; readonly start: number; readonly end: number; readonly entries: readonly Entry[] };
6
+
7
+ type Tree = Container | { readonly kind: "scalar"; readonly start: number; readonly end: number; readonly value: unknown };
8
+
9
+ type Edit = { readonly start: number; readonly end: number; readonly text: string };
10
+
11
+ export type Step = string | Readonly<Record<string, unknown>>;
12
+
13
+ type Reached = { readonly container: Container; readonly entry: Entry } | { readonly container: Container; readonly missing: readonly [Step, ...Step[]] };
14
+
15
+ const SCALAR_END = /[\s,:\]}/]/;
16
+
17
+ class Scanner {
18
+ readonly text: string;
19
+ at = 0;
20
+
21
+ constructor(text: string) {
22
+ this.text = text;
23
+ }
24
+
25
+ blank(): void {
26
+ for (;;) {
27
+ while (/\s/.test(this.text.charAt(this.at))) this.at += 1;
28
+ if (this.text.startsWith("//", this.at)) this.at = this.text.includes("\n", this.at) ? this.text.indexOf("\n", this.at) : this.text.length;
29
+ else if (this.text.startsWith("/*", this.at)) this.at = this.text.indexOf("*/", this.at + 2) + 2;
30
+ else return;
31
+ }
32
+ }
33
+
34
+ quoted(): string {
35
+ const start = this.at;
36
+ const quote = this.text.charAt(start);
37
+ this.at += 1;
38
+ while (this.at < this.text.length && this.text.charAt(this.at) !== quote) this.at += this.text.charAt(this.at) === "\\" ? 2 : 1;
39
+ this.at += 1;
40
+ return this.text.slice(start, this.at);
41
+ }
42
+
43
+ scalar(): Tree {
44
+ const start = this.at;
45
+ const opening = this.text.charAt(start);
46
+ if (opening === '"' || opening === "'") this.quoted();
47
+ else while (this.at < this.text.length && !SCALAR_END.test(this.text.charAt(this.at))) this.at += 1;
48
+ return { kind: "scalar", start, end: this.at, value: Bun.JSONC.parse(this.text.slice(start, this.at)) };
49
+ }
50
+
51
+ container(kind: Container["kind"]): Container {
52
+ const start = this.at;
53
+ const close = kind === "object" ? "}" : "]";
54
+ const entries: Entry[] = [];
55
+ this.at += 1;
56
+ for (this.blank(); this.at < this.text.length && this.text.charAt(this.at) !== close; this.blank()) {
57
+ const entryStart = this.at;
58
+ const key = kind === "object" ? this.member() : entries.length;
59
+ const node = this.node();
60
+ this.blank();
61
+ const comma = this.text.charAt(this.at) === "," ? this.at : undefined;
62
+ if (comma !== undefined) this.at += 1;
63
+ entries.push({ key, start: entryStart, node, comma });
64
+ }
65
+ this.at += 1;
66
+ return { kind, start, end: this.at, entries };
67
+ }
68
+
69
+ member(): string {
70
+ const key = String(Bun.JSONC.parse(this.quoted()));
71
+ this.blank();
72
+ this.at += 1;
73
+ return key;
74
+ }
75
+
76
+ node(): Tree {
77
+ this.blank();
78
+ const opening = this.text.charAt(this.at);
79
+ if (opening === "{") return this.container("object");
80
+ if (opening === "[") return this.container("array");
81
+ return this.scalar();
82
+ }
83
+ }
84
+
85
+ function valueOf(text: string, tree: Tree): unknown {
86
+ return tree.kind === "scalar" ? tree.value : Bun.JSONC.parse(text.slice(tree.start, tree.end));
87
+ }
88
+
89
+ function holds(text: string, tree: Tree, match: Readonly<Record<string, unknown>>): boolean {
90
+ if (tree.kind !== "object") return false;
91
+ return Object.entries(match).every(([key, wanted]) => {
92
+ const member = tree.entries.findLast((entry) => entry.key === key);
93
+ return member !== undefined && Bun.deepEquals(valueOf(text, member.node), wanted);
94
+ });
95
+ }
96
+
97
+ function childOf(text: string, container: Container, step: Step): Entry | undefined {
98
+ if (typeof step === "string") return container.kind === "object" ? container.entries.findLast(({ key }) => key === step) : undefined;
99
+ return container.kind === "array" ? container.entries.find(({ node }) => holds(text, node, step)) : undefined;
100
+ }
101
+
102
+ // Every step but the last reaches a container, an object for a key and an array for a match.
103
+ function reached(text: string, path: readonly [Step, ...Step[]]): Reached | undefined {
104
+ let container = new Scanner(text).node();
105
+ let steps = path;
106
+ for (;;) {
107
+ if (container.kind === "scalar") return undefined;
108
+ const [step, ...tail] = steps;
109
+ const entry = childOf(text, container, step);
110
+ if (entry === undefined) return { container, missing: steps };
111
+ const [next, ...after] = tail;
112
+ if (next === undefined) return { container, entry };
113
+ container = entry.node;
114
+ steps = [next, ...after];
115
+ }
116
+ }
117
+
118
+ function childValue(step: Step, tail: readonly Step[], value: unknown): unknown {
119
+ const [next, ...after] = tail;
120
+ const inner = next === undefined ? value : typeof next === "string" ? { [next]: childValue(next, after, value) } : [childValue(next, after, value)];
121
+ return typeof step === "string" ? inner : Object.assign({}, step, inner);
122
+ }
123
+
124
+ function indentAt(text: string, at: number): string {
125
+ const line = text.slice(text.lastIndexOf("\n", at - 1) + 1, at);
126
+ return line.slice(0, line.length - line.trimStart().length);
127
+ }
128
+
129
+ function serialized(value: unknown, indent: string): string {
130
+ return JSON.stringify(value, null, 2).replaceAll("\n", `\n${indent}`);
131
+ }
132
+
133
+ function spacesFrom(text: string, at: number, step: 1 | -1): number {
134
+ let edge = at;
135
+ while (/[ \t]/.test(text.charAt(step === 1 ? edge : edge - 1))) edge += step;
136
+ return edge;
137
+ }
138
+
139
+ function newlineAt(text: string, at: number): number | undefined {
140
+ if (text.startsWith("\r\n", at)) return 2;
141
+ return text.charAt(at) === "\n" ? 1 : undefined;
142
+ }
143
+
144
+ function lineEndAfter(text: string, at: number): number | undefined {
145
+ let edge = spacesFrom(text, at, 1);
146
+ for (;;) {
147
+ if (text.startsWith("//", edge)) edge = text.includes("\n", edge) ? text.indexOf("\n", edge) : text.length;
148
+ else if (text.startsWith("/*", edge)) edge = spacesFrom(text, text.indexOf("*/", edge + 2) + 2, 1);
149
+ else return newlineAt(text, edge) === undefined ? undefined : edge;
150
+ }
151
+ }
152
+
153
+ function insertion(text: string, { start, end, entries }: Container, step: Step, value: unknown): Edit {
154
+ const key = typeof step === "string" ? `${JSON.stringify(step)}: ` : "";
155
+ const last = entries.at(-1);
156
+ if (last === undefined) {
157
+ const indent = indentAt(text, start);
158
+ return { start: start + 1, end: end - 1, text: `${text.slice(start + 1, end - 1).trimEnd()}\n${indent} ${key}${serialized(value, `${indent} `)}\n${indent}` };
159
+ }
160
+ const indent = indentAt(text, last.start);
161
+ const member = `${key}${serialized(value, indent)}${last.comma === undefined ? "" : ","}`;
162
+ const separated = last.comma === undefined ? "," : "";
163
+ const lineEnd = lineEndAfter(text, last.comma === undefined ? last.node.end : last.comma + 1);
164
+ const until = lineEnd ?? (last.comma === undefined ? last.node.end : last.comma + 1);
165
+ return { start: last.node.end, end: until, text: `${separated}${text.slice(last.node.end, until)}${lineEnd === undefined ? " " : `\n${indent}`}${member}` };
166
+ }
167
+
168
+ function removal(text: string, { entries }: Container, entry: Entry): readonly Edit[] {
169
+ const index = entries.indexOf(entry);
170
+ const followed = index < entries.length - 1;
171
+ const end = entry.comma === undefined ? entry.node.end : entry.comma + 1;
172
+ const lineStart = spacesFrom(text, entry.start, -1);
173
+ const lineEnd = spacesFrom(text, end, 1);
174
+ const newline = newlineAt(text, lineEnd);
175
+ const before = entries[index - 1]?.comma;
176
+ const comma = !followed && entry.comma === undefined && before !== undefined ? [{ start: before, end: before + 1, text: "" }] : [];
177
+ if (newline !== undefined && (lineStart === 0 || text.charAt(lineStart - 1) === "\n")) return [...comma, { start: lineStart, end: lineEnd + newline, text: "" }];
178
+ return [...comma, followed ? { start: entry.start, end: lineEnd, text: "" } : { start: lineStart, end, text: "" }];
179
+ }
180
+
181
+ function applied(text: string, edits: readonly Edit[]): string {
182
+ return edits.toSorted((one, other) => other.start - one.start).reduce((out, { start, end, text: replacement }) => out.slice(0, start) + replacement + out.slice(end), text);
183
+ }
184
+
185
+ // The text parses as JSONC.
186
+ export function hasMember(text: string, path: readonly [Step, ...Step[]]): boolean {
187
+ const found = reached(text, path);
188
+ return found !== undefined && !("missing" in found);
189
+ }
190
+
191
+ // The text parses as JSONC, and value is a JSON value.
192
+ export function withMember(text: string, path: readonly [Step, ...Step[]], value: unknown): string {
193
+ const found = reached(text, path);
194
+ if (found === undefined) return text;
195
+ if ("missing" in found) {
196
+ const [step, ...tail] = found.missing;
197
+ return applied(text, [insertion(text, found.container, step, childValue(step, tail, value))]);
198
+ }
199
+ const { node, start } = found.entry;
200
+ if (Bun.deepEquals(valueOf(text, node), value)) return text;
201
+ return applied(text, [{ start: node.start, end: node.end, text: serialized(value, indentAt(text, start)) }]);
202
+ }
203
+
204
+ // The text parses as JSONC.
205
+ export function withoutMember(text: string, path: readonly [Step, ...Step[]]): string {
206
+ const found = reached(text, path);
207
+ if (found === undefined || "missing" in found) return text;
208
+ return applied(text, removal(text, found.container, found.entry));
209
+ }
@@ -0,0 +1,148 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import type { IConfiguration, ICruiseOptions, IForbiddenRuleType } from "dependency-cruiser";
3
+
4
+ // dependency-cruiser's type requires builtInModules.override beside add, while an empty override would drop every built-in.
5
+ type CruiseOptions = Omit<ICruiseOptions, "builtInModules"> & {
6
+ readonly builtInModules?: Partial<NonNullable<ICruiseOptions["builtInModules"]>>;
7
+ };
8
+
9
+ export type CruiseConfig = Omit<IConfiguration, "extends" | "options"> & { readonly options?: CruiseOptions };
10
+
11
+ export type ChecksCruiseConfig = CruiseConfig & {
12
+ readonly devOnly?: readonly string[];
13
+ readonly orphans?: readonly string[];
14
+ };
15
+
16
+ export const DEV_ONLY = ["^tests/"] as const;
17
+
18
+ const TEST_FILES = "[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$";
19
+
20
+ const MANIFEST = "package.json";
21
+
22
+ const CONFIG_FILES = "(^|/)[^/]*[.]config[.](?:js|cjs|mjs|ts|cts|mts)$";
23
+
24
+ type ExcludeObject = Exclude<NonNullable<ICruiseOptions["exclude"]>, string | readonly string[]>;
25
+
26
+ const BASE = {
27
+ forbidden: [
28
+ {
29
+ name: "no-circular",
30
+ severity: "error",
31
+ comment: "A circular relationship never has a single entry point, so invert one side.",
32
+ from: {},
33
+ to: { circular: true },
34
+ },
35
+ {
36
+ name: "no-orphans",
37
+ severity: "error",
38
+ comment: "Nothing reaches this module. Use it, remove it, or exempt the entry point by name in your own config.",
39
+ from: {
40
+ orphan: true,
41
+ pathNot: [
42
+ "(^|/)[.][^/]+[.](?:js|cjs|mjs|ts|cts|mts|json)$",
43
+ "[.]d[.]ts$",
44
+ "(^|/)tsconfig[.]json$",
45
+ "(^|/)(?:babel|webpack)[.]config[.](?:js|cjs|mjs|ts|cts|mts|json)$",
46
+ CONFIG_FILES,
47
+ TEST_FILES,
48
+ ],
49
+ },
50
+ to: {},
51
+ },
52
+ {
53
+ name: "not-to-dev-dep",
54
+ severity: "error",
55
+ comment:
56
+ "Shipped source cannot rely on a package that is absent in production. Move it to dependencies or peerDependencies, or keep the import in a test or config file.",
57
+ from: { pathNot: [TEST_FILES, CONFIG_FILES] },
58
+ to: { dependencyTypes: ["npm-dev"], dependencyTypesNot: ["type-only", "npm-peer"] },
59
+ },
60
+ {
61
+ name: "no-non-package-json",
62
+ severity: "error",
63
+ comment:
64
+ "The import resolves to an installed package the nearest package.json does not declare, so it holds only while something else keeps it hoisted. Declare it in dependencies, devDependencies or peerDependencies.",
65
+ from: {},
66
+ // dependency-cruiser reads every package.json key holding "ependencies" as a list, so peerDependenciesMeta tags a declared peer npm-no-pkg too.
67
+ to: { dependencyTypes: ["npm-no-pkg", "npm-unknown"], dependencyTypesNot: ["npm", "npm-dev", "npm-optional", "npm-peer"] },
68
+ },
69
+ {
70
+ name: "not-to-unresolvable",
71
+ severity: "error",
72
+ comment: "Nothing installed answers to this specifier. Install the package or fix the path.",
73
+ from: {},
74
+ to: { couldNotResolve: true },
75
+ },
76
+ {
77
+ name: "no-deep-imports",
78
+ severity: "error",
79
+ comment:
80
+ "The specifier reaches past the package name into a subpath its exports map does not publish. Depend on a published entry instead.",
81
+ from: {},
82
+ to: { couldNotResolve: true, path: "^(@[^/]+/[^/]+|[^@./#][^/]*)/" },
83
+ },
84
+ ],
85
+ options: {
86
+ parser: "swc",
87
+ builtInModules: { add: ["bun"] },
88
+ doNotFollow: { path: ["node_modules"] },
89
+ // The cruise follows the repos/ links without this.
90
+ exclude: { path: "^repos/" },
91
+ enhancedResolveOptions: {
92
+ exportsFields: ["exports"],
93
+ conditionNames: ["types", "import", "require", "node", "default"],
94
+ },
95
+ },
96
+ } satisfies CruiseConfig;
97
+
98
+ export const base: CruiseConfig = BASE;
99
+
100
+ function asList(value: string | readonly string[] | undefined): readonly string[] {
101
+ if (value === undefined) return [];
102
+ return typeof value === "string" ? [value] : value;
103
+ }
104
+
105
+ function widened(rule: IForbiddenRuleType, extra: readonly string[]): IForbiddenRuleType {
106
+ if (extra.length === 0 || !("pathNot" in rule.from)) return rule;
107
+ return { ...rule, from: { ...rule.from, pathNot: [...asList(rule.from.pathNot), ...extra] } };
108
+ }
109
+
110
+ function kitRule(rule: IForbiddenRuleType, devOnly: readonly string[], orphans: readonly string[]): IForbiddenRuleType {
111
+ if (rule.name === "not-to-dev-dep") return widened(rule, devOnly);
112
+ if (rule.name === "no-orphans") return widened(rule, orphans);
113
+ return rule;
114
+ }
115
+
116
+ function excludeObject(exclude: ICruiseOptions["exclude"]): ExcludeObject {
117
+ if (exclude === undefined || typeof exclude === "string" || Array.isArray(exclude)) return { path: [...asList(exclude)] };
118
+ return exclude;
119
+ }
120
+
121
+ function joinedExclude(exclude: ICruiseOptions["exclude"]): ExcludeObject {
122
+ const own = excludeObject(exclude);
123
+ return { ...own, path: [...asList(BASE.options.exclude.path), ...asList(own.path)] };
124
+ }
125
+
126
+ function declaresAstro(manifest: unknown): boolean {
127
+ if (typeof manifest !== "object" || manifest === null) return false;
128
+ return ["dependencies", "devDependencies"].some((field) => {
129
+ const declared: unknown = Reflect.get(manifest, field);
130
+ return typeof declared === "object" && declared !== null && "astro" in declared;
131
+ });
132
+ }
133
+
134
+ // The cruise never reads an .astro importer, so a module only a page imports would read as an orphan.
135
+ function dependsOnAstro(): boolean {
136
+ return existsSync(MANIFEST) && declaresAstro(JSON.parse(readFileSync(MANIFEST, "utf8")));
137
+ }
138
+
139
+ export function defineConfig({ devOnly = DEV_ONLY, orphans = [], forbidden = [], options, ...rest }: ChecksCruiseConfig = {}): CruiseConfig {
140
+ const named = new Set(forbidden.map(({ name }) => name));
141
+ if (dependsOnAstro()) named.add("no-orphans");
142
+ const kitRules = BASE.forbidden.filter(({ name }) => !named.has(name)).map((rule) => kitRule(rule, devOnly, orphans));
143
+ return {
144
+ ...rest,
145
+ forbidden: [...kitRules, ...forbidden],
146
+ options: { ...BASE.options, ...options, exclude: joinedExclude(options?.exclude) },
147
+ };
148
+ }
@@ -0,0 +1,165 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import type { OxlintConfig, OxlintOverride } from "oxlint";
3
+
4
+ type Globs = readonly [string, ...string[]];
5
+
6
+ export type EffectScope = boolean | { readonly files?: Globs; readonly excludeFiles?: readonly string[] };
7
+
8
+ export type ChecksConfig = Omit<OxlintConfig, "extends" | "plugins" | "jsPlugins" | "categories"> & {
9
+ readonly effect: EffectScope;
10
+ };
11
+
12
+ const SIZE_RULES = ["max-lines", "max-lines-per-function", "max-statements", "readability/cognitive-complexity", "max-depth"] as const;
13
+
14
+ export type SizeRule = (typeof SIZE_RULES)[number];
15
+
16
+ export type SizeLimits = Readonly<Record<SizeRule, number | "off">>;
17
+
18
+ export const SOURCE_LIMITS = {
19
+ "max-lines": 400,
20
+ "max-lines-per-function": 100,
21
+ "max-statements": 30,
22
+ "readability/cognitive-complexity": 15,
23
+ "max-depth": 4,
24
+ } as const satisfies SizeLimits;
25
+
26
+ export const TEST_LIMITS = {
27
+ "max-lines": 600,
28
+ "max-lines-per-function": "off",
29
+ "max-statements": 50,
30
+ "readability/cognitive-complexity": 15,
31
+ "max-depth": 4,
32
+ } as const satisfies SizeLimits;
33
+
34
+ export const SOURCES = ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"] as const;
35
+
36
+ export const TESTS = ["tests/**", "**/*.test.ts", "**/*.test.tsx"] as const;
37
+
38
+ const PLUGINS = ["typescript", "oxc", "eslint", "import"] as const;
39
+
40
+ const EFFECT_ONLY_PLUGINS = ["node", "promise", "unicorn"] as const;
41
+
42
+ const EFFECT_PLUGINS = [...PLUGINS, ...EFFECT_ONLY_PLUGINS] as const;
43
+
44
+ const JS_PLUGINS = ["dist/effect-channel/index.js", "dist/readability/index.js", "dist/data-shape/index.js"] as const;
45
+
46
+ const EFFECT_RULES = {
47
+ "node/no-sync": "error",
48
+ "oxc/no-async-await": "error",
49
+ "promise/avoid-new": "error",
50
+ "unicorn/no-process-exit": "error",
51
+ "effect-channel/no-throw": "error",
52
+ "effect-channel/no-try-catch": "error",
53
+ } as const;
54
+
55
+ const LINE_RULES: ReadonlySet<SizeRule> = new Set(["max-lines", "max-lines-per-function"]);
56
+
57
+ const REFUSED_EFFECT = "@avi2dg/checks/oxlint: set effect to true, false, or { files, excludeFiles } with at least one glob in files";
58
+
59
+ // oxlint reads each relative jsPlugins path against the consumer's config, not against this package.
60
+ function packagePath(path: string): string {
61
+ return fileURLToPath(new URL(`../../${path}`, import.meta.url));
62
+ }
63
+
64
+ const BASE = {
65
+ plugins: [...PLUGINS],
66
+ jsPlugins: JS_PLUGINS.map(packagePath),
67
+ categories: { correctness: "error", suspicious: "error" },
68
+ rules: {
69
+ "effect-channel/no-error-channel-escape": "error",
70
+ "typescript/no-explicit-any": "error",
71
+ "typescript/ban-ts-comment": ["error", { "ts-expect-error": true }],
72
+ "typescript/no-inferrable-types": "error",
73
+ "typescript/explicit-module-boundary-types": "error",
74
+ "typescript/switch-exhaustiveness-check": "error",
75
+ "typescript/prefer-readonly": "error",
76
+ "typescript/no-unnecessary-condition": ["error", { allowConstantLoopConditions: true }],
77
+ "typescript/no-unnecessary-type-parameters": "error",
78
+ "typescript/use-unknown-in-catch-callback-variable": "error",
79
+ "eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", ignoreRestSiblings: true }],
80
+ "typescript/no-unsafe-type-assertion": "error",
81
+ "typescript/no-non-null-assertion": "error",
82
+ "typescript/no-deprecated": "error",
83
+ "typescript/consistent-return": "off",
84
+ },
85
+ overrides: [
86
+ {
87
+ files: ["**/*.ts", "**/*.tsx"],
88
+ rules: { "data-shape/readonly-collection-param": "error" },
89
+ },
90
+ {
91
+ files: ["**/*.ts", "**/*.tsx"],
92
+ excludeFiles: ["tests/**"],
93
+ rules: {
94
+ "data-shape/schema-twin": "error",
95
+ "typescript/no-unsafe-assignment": "error",
96
+ "typescript/no-unsafe-member-access": "error",
97
+ "typescript/no-unsafe-argument": "error",
98
+ "typescript/no-unsafe-return": "error",
99
+ "typescript/no-unsafe-call": "error",
100
+ },
101
+ },
102
+ {
103
+ files: ["**/*.astro"],
104
+ rules: { "readability/thin-astro": "error", "import/no-unassigned-import": "off" },
105
+ },
106
+ ],
107
+ } satisfies OxlintConfig;
108
+
109
+ export const base: OxlintConfig = BASE;
110
+
111
+ function sizeSetting(rule: SizeRule, limit: number | "off"): NonNullable<OxlintConfig["rules"]>[string] {
112
+ if (limit === "off") return "off";
113
+ return LINE_RULES.has(rule) ? ["error", { max: limit, skipBlankLines: false, skipComments: false }] : ["error", { max: limit }];
114
+ }
115
+
116
+ export function sizeBudget(files: readonly string[], limits: SizeLimits, excludeFiles: readonly string[] = []): OxlintOverride {
117
+ const rules = Object.fromEntries(SIZE_RULES.map((rule) => [rule, sizeSetting(rule, limits[rule])]));
118
+ return { files: [...files], excludeFiles: [...excludeFiles], rules };
119
+ }
120
+
121
+ export function effectRules(files: readonly string[], excludeFiles: readonly string[] = []): OxlintOverride {
122
+ return { files: [...files], excludeFiles: [...excludeFiles], plugins: [...EFFECT_PLUGINS], rules: { ...EFFECT_RULES } };
123
+ }
124
+
125
+ function isGlobList(value: unknown): value is readonly string[] {
126
+ return Array.isArray(value) && value.every((glob) => typeof glob === "string" && glob !== "");
127
+ }
128
+
129
+ // The types already refuse each case below, and the guard repeats them for a config no typecheck reads.
130
+ function effectOverrides(effect: unknown): readonly OxlintOverride[] {
131
+ if (effect === true) return [effectRules(SOURCES, TESTS)];
132
+ if (effect === false) return [];
133
+ if (typeof effect !== "object" || effect === null) throw new TypeError(REFUSED_EFFECT);
134
+ const files = "files" in effect ? effect.files : undefined;
135
+ const excludeFiles = "excludeFiles" in effect ? effect.excludeFiles : [];
136
+ if (!isGlobList(excludeFiles)) throw new TypeError(REFUSED_EFFECT);
137
+ if (files === undefined) return [effectRules(SOURCES, [...TESTS, ...excludeFiles])];
138
+ if (!isGlobList(files) || files.length === 0) throw new TypeError(REFUSED_EFFECT);
139
+ return [effectRules(files, excludeFiles)];
140
+ }
141
+
142
+ // oxlint drops an override's setting for a rule whose plugin is neither top-level nor named in that override.
143
+ // Naming only that plugin turns on its correctness and suspicious rules, and naming the Effect override's set does not.
144
+ function withRulePlugins(override: OxlintOverride): OxlintOverride {
145
+ const rules = Object.keys(override.rules ?? {});
146
+ const namesEffectOnlyRule = rules.some((rule) => EFFECT_ONLY_PLUGINS.some((plugin) => rule.startsWith(`${plugin}/`)));
147
+ if (!namesEffectOnlyRule) return override;
148
+ return { ...override, plugins: [...new Set([...EFFECT_PLUGINS, ...(override.plugins ?? [])])] };
149
+ }
150
+
151
+ export function defineConfig({ effect, rules, overrides = [], options, ...rest }: ChecksConfig): OxlintConfig {
152
+ return {
153
+ ...BASE,
154
+ ...rest,
155
+ options: { typeAware: true, ...options },
156
+ rules: { ...BASE.rules, ...rules },
157
+ overrides: [
158
+ ...BASE.overrides,
159
+ sizeBudget(SOURCES, SOURCE_LIMITS, TESTS),
160
+ sizeBudget(TESTS, TEST_LIMITS),
161
+ ...effectOverrides(effect),
162
+ ...overrides.map(withRulePlugins),
163
+ ],
164
+ };
165
+ }
package/stryker.preset.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { readdirSync } from "node:fs";
1
2
  import { availableParallelism } from "node:os";
2
3
  import { fileURLToPath } from "node:url";
3
4
  import { GUARD_IGNORER } from "./src/testing/mutation-guard-plugin.js";
@@ -9,6 +10,13 @@ if (refusal !== undefined) throw new Error(refusal);
9
10
 
10
11
  export const halfAvailableCores = (cores = availableParallelism()) => Math.max(1, Math.floor(cores / 2));
11
12
 
13
+ // The bun runner narrows a mutant's run to its covering files only when each listed path equals a test id's file part, which carries no leading ./.
14
+ export const unitTestFiles = () =>
15
+ readdirSync("tests/unit", { recursive: true, encoding: "utf8" })
16
+ .filter((name) => name.endsWith(".test.ts") || name.endsWith(".test.tsx"))
17
+ .map((name) => `tests/unit/${name}`)
18
+ .toSorted();
19
+
12
20
  export default {
13
21
  packageManager: "npm",
14
22
  plugins: [
@@ -1,11 +0,0 @@
1
- {
2
- "plugins": ["node", "promise", "unicorn"],
3
- "rules": {
4
- "node/no-sync": "error",
5
- "oxc/no-async-await": "error",
6
- "promise/avoid-new": "error",
7
- "unicorn/no-process-exit": "error",
8
- "effect-channel/no-throw": "error",
9
- "effect-channel/no-try-catch": "error"
10
- }
11
- }