@sarj/eslint-plugin 18.3.1 → 19.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,295 +1,150 @@
1
1
  // src/verify-rule-examples.ts
2
+ import { AssertionError } from "assert";
2
3
  import { mkdtemp, mkdir, rm, writeFile, readFile, symlink } from "fs/promises";
3
4
  import { tmpdir } from "os";
4
- import { dirname as dirname2, join, resolve as resolve2, sep } from "path";
5
- import * as parser2 from "@typescript-eslint/parser";
6
- import { Linter } from "eslint";
7
-
8
- // src/create-example-parser.ts
9
- import { basename, dirname, resolve } from "path";
10
- import * as parser from "@typescript-eslint/parser";
11
- import ts from "typescript";
12
- var WATCH_DEFAULTS = {
13
- noEmit: true,
14
- noUnusedLocals: true,
15
- noUnusedParameters: true,
5
+ import { dirname, join, resolve, sep } from "path";
6
+ import { parseSync } from "oxc-parser";
7
+ import { RuleTester } from "oxlint/plugins-dev";
8
+ var UNREACHABLE_OUTPUT = "\0sarj-rule-examples: actual fixed source\0";
9
+ var DEFAULT_TSCONFIG = JSON.stringify({ compilerOptions: {
10
+ target: "ESNext",
11
+ module: "ESNext",
12
+ moduleResolution: "Bundler",
13
+ strict: true,
16
14
  allowJs: true,
17
- allowNonTsExtensions: true,
18
- checkJs: true
19
- };
20
- var MAX_LIBRARY_FILES = 256;
21
- var MAX_LIBRARY_BYTES = 16 * 1024 * 1024;
22
- function createExampleParser() {
23
- const libraryRoot = ts.sys.realpath?.(dirname(ts.getDefaultLibFilePath({})));
24
- const cache = /* @__PURE__ */ new Map();
25
- const registries = /* @__PURE__ */ new Map();
26
- const counts = { programs: 0, libraryAcquires: 0, libraryHits: 0, nativeFallbacks: 0 };
27
- let bytes = 0;
28
- return {
29
- meta: parser.meta,
30
- clear() {
31
- cache.clear();
32
- registries.clear();
33
- bytes = 0;
34
- },
35
- statistics() {
36
- return { ...counts, libraryFiles: cache.size, libraryBytes: bytes };
37
- },
38
- parseForESLint(code, options) {
39
- const fallback = () => {
40
- counts.nativeFallbacks += 1;
41
- return parser.parseForESLint(code, options);
42
- };
43
- if (!Object.hasOwn(ts, "JSDocParsingMode")) return fallback();
44
- if (typeof options?.project !== "string" || typeof options.filePath !== "string" || options.programs != null || options.projectService || options.extraFileExtensions?.length || options.debugLevel) {
45
- return fallback();
46
- }
47
- const config = resolve(options.tsconfigRootDir ?? process.cwd(), options.project);
48
- let unrecoverable = false;
49
- const parsed = ts.getParsedCommandLineOfConfigFile(config, WATCH_DEFAULTS, {
50
- ...ts.sys,
51
- onUnRecoverableConfigFileDiagnostic() {
52
- unrecoverable = true;
53
- }
54
- });
55
- if (unrecoverable || parsed === void 0 || parsed.errors.length > 0 || parsed.projectReferences?.length) {
56
- return fallback();
57
- }
58
- const filename = resolve(options.filePath);
59
- const host = ts.createCompilerHost(parsed.options, true);
60
- const canonical = (path) => host.getCanonicalFileName(resolve(path));
61
- if (!parsed.fileNames.some((path) => canonical(path) === canonical(filename))) return fallback();
62
- const mode = options.jsDocParsingMode === "none" ? ts.JSDocParsingMode.ParseNone : options.jsDocParsingMode === "type-info" ? ts.JSDocParsingMode.ParseForTypeInfo : ts.JSDocParsingMode.ParseAll;
63
- host.jsDocParsingMode = mode;
64
- let registry = registries.get(mode);
65
- if (registry === void 0) {
66
- registry = ts.createDocumentRegistry(ts.sys.useCaseSensitiveFileNames, process.cwd(), mode);
67
- registries.set(mode, registry);
68
- }
69
- const settingsKey = registry.getKeyForCompilationSettings(parsed.options);
70
- const read = host.readFile.bind(host);
71
- host.readFile = (path) => canonical(path) === canonical(filename) ? code : read(path);
72
- const original = host.getSourceFile.bind(host);
73
- host.getSourceFile = (path, language, onError, fresh) => {
74
- if (fresh || libraryRoot === void 0 || host.realpath === void 0 || canonical(path) === canonical(filename) || dirname(resolve(path)) !== libraryRoot || !/^lib(?:\..+)?\.d\.ts$/u.test(basename(path)) || !host.fileExists(path)) {
75
- return original(path, language, onError, fresh);
76
- }
77
- try {
78
- if (host.realpath(path) !== resolve(path)) return original(path, language, onError, fresh);
79
- } catch {
80
- return original(path, language, onError, fresh);
81
- }
82
- const text = read(path);
83
- if (text === void 0) return original(path, language, onError, fresh);
84
- const version = typeof language === "number" ? language : language.languageVersion;
85
- const format = typeof language === "number" ? void 0 : language.impliedNodeFormat;
86
- const key = JSON.stringify([path, settingsKey, version, format, mode]);
87
- const previous = cache.get(key);
88
- if (previous?.text === text) {
89
- counts.libraryHits += 1;
90
- return previous.source;
91
- }
92
- const size = Buffer.byteLength(text);
93
- if (previous !== void 0 || cache.size >= MAX_LIBRARY_FILES || bytes + size > MAX_LIBRARY_BYTES) {
94
- return original(path, language, onError, fresh);
95
- }
96
- const source = registry.acquireDocument(
97
- path,
98
- parsed.options,
99
- ts.ScriptSnapshot.fromString(text),
100
- "unchanged-native-library",
101
- ts.ScriptKind.TS,
102
- language
103
- );
104
- cache.set(key, { text, source });
105
- bytes += size;
106
- counts.libraryAcquires += 1;
107
- return source;
108
- };
109
- const program = ts.createProgram(parsed.fileNames, parsed.options, host);
110
- counts.programs += 1;
111
- if (program.getSourceFile(filename)?.text !== code) {
112
- throw new Error("The example compiler did not load the current lint source.");
113
- }
114
- return parser.parseForESLint(code, {
115
- ...options,
116
- project: false,
117
- projectService: false,
118
- programs: [program],
119
- disallowAutomaticSingleRunInference: true
120
- });
121
- }
122
- };
123
- }
124
-
125
- // src/verify-rule-examples.ts
15
+ jsx: "preserve",
16
+ noEmit: true
17
+ }, include: ["**/*"] });
126
18
  async function verifyRuleExamples(rule, setup = {}) {
127
19
  const spec = rule.documentation;
128
- if (spec === void 0 || !spec.publicExamples.some((item) => item.outcome === "match") || !spec.publicExamples.some((item) => item.outcome === "no-match")) {
20
+ if (!spec.publicExamples.some((item) => item.outcome === "match") || !spec.publicExamples.some((item) => item.outcome === "no-match")) {
129
21
  throw new Error("rule requires documented public matching and non-matching examples");
130
22
  }
131
- const exampleParser = createExampleParser();
132
- try {
133
- for (const example of spec.examples) {
134
- const root = await mkdtemp(join(tmpdir(), "sarj-rule-example-"));
135
- try {
136
- for (const file of example.files) {
137
- const path = fixturePath(root, file.path);
138
- validateFixtureSyntax(example.id, file, path);
139
- await mkdir(dirname2(path), { recursive: true });
140
- await writeFile(path, file.source);
141
- }
142
- for (const dependency of example.installedDependencies ?? []) {
143
- if (setup.installedDependencyRoot === void 0) throw new Error(`${example.id}: explicit installed dependency root is required`);
144
- const packageFile = fixturePath(resolve2(setup.installedDependencyRoot), `node_modules/${dependency.module}/package.json`);
145
- const metadata = JSON.parse(await readFile(packageFile, "utf8"));
146
- if (typeof metadata !== "object" || metadata === null || !("name" in metadata) || !("version" in metadata) || metadata.name !== dependency.module || metadata.version !== dependency.version) throw new Error(`${example.id}: installed dependency identity/version mismatch for ${dependency.module}`);
147
- const destination = fixturePath(root, `node_modules/${dependency.module}`);
148
- await mkdir(dirname2(destination), { recursive: true });
149
- await symlink(dirname2(packageFile), destination, process.platform === "win32" ? "junction" : "dir");
150
- }
151
- const tsconfig = join(root, "tsconfig.json");
152
- if (!example.files.some((file) => file.path === "tsconfig.json")) {
153
- await writeFile(tsconfig, JSON.stringify({ compilerOptions: {
154
- target: "ESNext",
155
- module: "ESNext",
156
- moduleResolution: "Bundler",
157
- strict: true,
158
- allowJs: true,
159
- jsx: "preserve",
160
- noEmit: true
161
- }, include: ["**/*"] }));
162
- }
163
- const ruleId = `@sarj/${spec.ruleId}`;
164
- const config = {
165
- files: ["**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"],
166
- languageOptions: { parser: exampleParser, parserOptions: { project: tsconfig, tsconfigRootDir: root, disallowAutomaticSingleRunInference: true } },
167
- plugins: { "@sarj": { rules: { [spec.ruleId]: rule } } },
168
- rules: { [ruleId]: ["error", ...example.ruleOptions ?? rule.meta.defaultOptions ?? rule.defaultOptions ?? []] }
169
- };
170
- await verifyExample(new Linter({ cwd: root }), config, example, root, ruleId, setup.formatFixedSource);
171
- } finally {
172
- parser2.clearCaches();
173
- await rm(root, { recursive: true, force: true });
174
- }
23
+ for (const example of spec.examples) {
24
+ const root = await mkdtemp(join(tmpdir(), "sarj-rule-example-"));
25
+ try {
26
+ await writeProject(root, example, setup);
27
+ await verifyExample(rule, example, root, setup.formatFixedSource);
28
+ } finally {
29
+ await rm(root, { recursive: true, force: true });
175
30
  }
176
- } finally {
177
- exampleParser.clear();
178
31
  }
179
32
  return spec.examples.length;
180
33
  }
34
+ async function writeProject(root, example, setup) {
35
+ for (const file of example.files) {
36
+ const path = fixturePath(root, file.path);
37
+ validateFixtureSyntax(example.id, file, path);
38
+ await mkdir(dirname(path), { recursive: true });
39
+ await writeFile(path, file.source);
40
+ }
41
+ for (const dependency of example.installedDependencies ?? []) {
42
+ if (setup.installedDependencyRoot === void 0) throw new Error(`${example.id}: explicit installed dependency root is required`);
43
+ const packageFile = fixturePath(resolve(setup.installedDependencyRoot), `node_modules/${dependency.module}/package.json`);
44
+ const metadata = JSON.parse(await readFile(packageFile, "utf8"));
45
+ if (typeof metadata !== "object" || metadata === null || !("name" in metadata) || !("version" in metadata) || metadata.name !== dependency.module || metadata.version !== dependency.version) throw new Error(`${example.id}: installed dependency identity/version mismatch for ${dependency.module}`);
46
+ const destination = fixturePath(root, `node_modules/${dependency.module}`);
47
+ await mkdir(dirname(destination), { recursive: true });
48
+ await symlink(dirname(packageFile), destination, process.platform === "win32" ? "junction" : "dir");
49
+ }
50
+ if (!example.files.some((file) => file.path === "tsconfig.json")) {
51
+ await writeFile(join(root, "tsconfig.json"), DEFAULT_TSCONFIG);
52
+ }
53
+ }
181
54
  function fixturePath(root, path) {
182
- const resolved = resolve2(root, path);
55
+ const resolved = resolve(root, path);
183
56
  if (!resolved.startsWith(root + sep)) throw new Error(`unsafe example path: ${path}`);
184
57
  return resolved;
185
58
  }
186
59
  function validateFixtureSyntax(exampleId, file, path) {
187
60
  if (!/\.[cm]?[jt]sx?$/u.test(file.path)) return;
188
- try {
189
- parser2.parse(file.source, { filePath: path });
190
- } catch (error) {
191
- throw new Error(`${exampleId}: invalid fixture ${file.path}`, { cause: error });
192
- }
61
+ const [error] = parseSync(path, file.source).errors;
62
+ if (error !== void 0) throw new Error(`${exampleId}: invalid fixture ${file.path}: ${error.message}`);
193
63
  }
194
- async function verifyExample(linter, config, example, root, ruleId, formatFixedSource) {
64
+ async function verifyExample(rule, example, root, formatFixedSource) {
195
65
  const focus = example.files.find((file) => file.path === example.focusPath);
196
66
  if (focus === void 0) throw new Error(`${example.id}: focus file is missing`);
197
- const filename = fixturePath(root, focus.path);
198
- const messages = linter.verify(focus.source, config, { filename });
199
- if (messages.some((message) => message.fatal || message.ruleId !== ruleId)) {
200
- throw new Error(`${example.id}: invalid fixture: ${messages.map((message) => message.message).join("; ")}`);
201
- }
202
- const lines = linter.getSourceCode().lines;
203
- if (messages.some((message) => !physicalDiagnosticLocation(message, lines))) {
204
- throw new Error(`${example.id}: invalid diagnostic location`);
205
- }
206
- const locations = messages.map((message) => JSON.stringify([
207
- message.ruleId,
208
- message.messageId ?? message.message,
209
- message.line,
210
- message.column,
211
- message.endLine,
212
- message.endColumn
213
- ]));
214
- if (new Set(locations).size !== locations.length) throw new Error(`${example.id}: duplicate diagnostic`);
215
- if (messages.length !== example.expectedCount) {
216
- throw new Error(`${example.id}: expected ${example.expectedCount} findings, received ${messages.length}`);
217
- }
218
- if (example.expectedDiagnostics !== void 0) {
219
- verifyExpectedDiagnostics(example, messages, focus.source, lines);
67
+ const base = {
68
+ code: focus.source,
69
+ filename: fixturePath(root, focus.path),
70
+ // Authored options are JSON data: the catalog publishes them.
71
+ ...example.ruleOptions !== void 0 && { options: [...example.ruleOptions] }
72
+ };
73
+ const name = rule.documentation.ruleId;
74
+ const tested = rule;
75
+ const tester = new RuleTester({ eslintCompat: true, cwd: root });
76
+ if (example.expectedCount === 0) {
77
+ run(example, () => {
78
+ tester.run(name, tested, { valid: [base], invalid: [] });
79
+ });
80
+ return;
220
81
  }
221
- for (const expected of example.fixedFiles ?? []) {
222
- const input = example.files.find((file) => file.path === expected.path);
223
- if (input === void 0) throw new Error(`${example.id}: fixed file has no original`);
224
- const options = { filename: fixturePath(root, input.path) };
225
- const fixed = linter.verifyAndFix(input.source, config, options);
226
- if (formatFixedSource === void 0 && fixed.output !== expected.source) throw new Error(`${example.id}: unexpected fixed source`);
227
- await writeFile(options.filename, fixed.output);
228
- const repeated = linter.verifyAndFix(fixed.output, config, options);
229
- if (repeated.fixed || repeated.output !== fixed.output || repeated.messages.length > 0) {
230
- throw new Error(`${example.id}: fix is not clean and idempotent`);
231
- }
232
- if (formatFixedSource !== void 0 && await formatFixedSource({ path: input.path, source: fixed.output }, example) !== expected.source) {
233
- throw new Error(`${example.id}: unexpected fixed source`);
82
+ const expected = example.expectedDiagnostics?.toSorted(byLocation);
83
+ const fixed = actualFixedSource(example, () => {
84
+ tester.run(name, tested, { valid: [], invalid: [{ ...base, errors: expected?.map(identity) ?? example.expectedCount, output: UNREACHABLE_OUTPUT, recursive: true }] });
85
+ });
86
+ if (expected !== void 0) {
87
+ actualFixedSource(example, () => {
88
+ tester.run(name, tested, { valid: [], invalid: [{ ...base, errors: expected.map(message), output: UNREACHABLE_OUTPUT, recursive: true }] });
89
+ });
90
+ if (expected.some((item) => item.automaticFix) !== (fixed !== focus.source)) {
91
+ throw new Error(`${example.id}: automatic fix delivery differs from the expected diagnostics`);
234
92
  }
235
93
  }
236
- }
237
- function verifyExpectedDiagnostics(example, messages, source, lines) {
238
- const expectations = example.expectedDiagnostics ?? [];
239
- if (expectations.some((item) => !physicalExpectedLocation(item.location, lines))) {
240
- throw new Error(`${example.id}: invalid expected diagnostic location`);
241
- }
242
- const expected = expectations.map(expectedDiagnosticKey).sort();
243
- const actual = messages.map((message) => JSON.stringify([
244
- message.messageId,
245
- message.message,
246
- [message.line, message.column, message.endLine, message.endColumn],
247
- message.fix !== void 0,
248
- (message.suggestions ?? []).map((suggestion) => [
249
- suggestion.messageId,
250
- applySuggestion(source, suggestion.fix, example.id)
251
- ])
252
- ])).sort();
253
- if (JSON.stringify(actual) !== JSON.stringify(expected)) {
254
- throw new Error(`${example.id}: unexpected diagnostics
255
- expected: ${JSON.stringify(expected)}
256
- received: ${JSON.stringify(actual)}`);
94
+ const expectedFixed = example.fixedFiles?.find((file) => file.path === focus.path);
95
+ if (expectedFixed === void 0) return;
96
+ if (formatFixedSource === void 0 && fixed !== expectedFixed.source) throw new Error(`${example.id}: unexpected fixed source`);
97
+ await writeFile(fixturePath(root, focus.path), fixed);
98
+ run(example, () => {
99
+ tester.run(name, tested, { valid: [{ ...base, code: fixed }], invalid: [] });
100
+ });
101
+ if (formatFixedSource !== void 0 && await formatFixedSource({ path: focus.path, source: fixed }, example) !== expectedFixed.source) {
102
+ throw new Error(`${example.id}: unexpected fixed source`);
257
103
  }
258
104
  }
259
- function expectedDiagnosticKey(expected) {
260
- return JSON.stringify([
261
- expected.messageId,
262
- expected.message,
263
- [expected.location.line, expected.location.column, expected.location.end?.line, expected.location.end?.column],
264
- expected.automaticFix,
265
- expected.suggestions.map((suggestion) => [suggestion.messageId, suggestion.output])
266
- ]);
105
+ function byLocation(left, right) {
106
+ const lines = left.location.line - right.location.line;
107
+ return lines === 0 ? left.location.column - right.location.column : lines;
267
108
  }
268
- function applySuggestion(source, fix, exampleId) {
269
- const bom = source.startsWith("\uFEFF") ? "\uFEFF" : "";
270
- const text = bom === "" ? source : source.slice(1);
271
- const [start, end] = fix.range;
272
- if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start < (bom === "" ? 0 : -1) || end < 0 || end > text.length) {
273
- throw new Error(`${exampleId}: invalid suggestion edit range`);
274
- }
275
- const prefix = start < 0 && end >= 0 || start === 0 && fix.text.startsWith("\uFEFF") ? "" : bom;
276
- return prefix + text.slice(0, Math.max(0, start)) + fix.text + text.slice(end);
109
+ function location({ location: { line, column, end } }) {
110
+ return { line, column, endLine: end?.line, endColumn: end?.column };
277
111
  }
278
- function physicalExpectedLocation(location, lines) {
279
- if (!physicalPosition(lines, location.line, location.column)) return false;
280
- if (location.end === void 0) return true;
281
- return physicalPosition(lines, location.end.line, location.end.column) && (location.end.line > location.line || location.end.line === location.line && location.end.column >= location.column);
112
+ function identity(expected) {
113
+ return {
114
+ ...location(expected),
115
+ messageId: expected.messageId,
116
+ suggestions: expected.suggestions.map((suggestion) => ({ messageId: suggestion.messageId, output: suggestion.output }))
117
+ };
282
118
  }
283
- function physicalDiagnosticLocation(message, lines) {
284
- if (!physicalPosition(lines, message.line, message.column)) return false;
285
- if (message.endLine === void 0 && message.endColumn === void 0) return true;
286
- if (!physicalPosition(lines, message.endLine, message.endColumn)) return false;
287
- return message.endLine !== void 0 && message.endColumn !== void 0 && (message.endLine > message.line || message.endLine === message.line && message.endColumn >= message.column);
119
+ function message(expected) {
120
+ return { ...location(expected), message: expected.message };
288
121
  }
289
- function physicalPosition(lines, line, column) {
290
- if (line === void 0 || column === void 0 || !Number.isInteger(line) || !Number.isInteger(column)) return false;
291
- const text = lines[line - 1];
292
- return text !== void 0 && column >= 1 && column <= text.length + 1;
122
+ function run(example, body) {
123
+ const { describe, it } = RuleTester;
124
+ RuleTester.describe = (_text, method) => {
125
+ method();
126
+ };
127
+ RuleTester.it = (_text, method) => {
128
+ method();
129
+ };
130
+ try {
131
+ body();
132
+ } catch (error) {
133
+ throw new Error(`${example.id}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
134
+ } finally {
135
+ RuleTester.describe = describe;
136
+ RuleTester.it = it;
137
+ }
138
+ }
139
+ function actualFixedSource(example, body) {
140
+ try {
141
+ run(example, body);
142
+ } catch (error) {
143
+ const { cause } = error;
144
+ if (cause instanceof AssertionError && cause.expected === UNREACHABLE_OUTPUT && typeof cause.actual === "string") return cause.actual;
145
+ throw error;
146
+ }
147
+ throw new Error(`${example.id}: RuleTester accepted an unreachable fixed source`);
293
148
  }
294
149
  export {
295
150
  verifyRuleExamples