xo 2.0.2 → 3.0.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.
package/dist/lib/xo.js CHANGED
@@ -6,15 +6,18 @@ import process from 'node:process';
6
6
  import { ESLint } from 'eslint';
7
7
  import findCacheDirectory from 'find-cache-directory';
8
8
  import { globby, isDynamicPattern } from 'globby';
9
+ import micromatch from 'micromatch';
9
10
  import arrify from 'arrify';
10
11
  import defineLazyProperty from 'define-lazy-prop';
11
- import prettier from 'prettier';
12
12
  import { defaultIgnores, cacheDirName, allExtensions, tsFilesGlob, tsconfigDefaults, } from './constants.js';
13
13
  import { xoToEslintConfig } from './xo-to-eslint.js';
14
14
  import resolveXoConfig from './resolve-config.js';
15
15
  import { handleTsconfig } from './handle-ts-files.js';
16
16
  import { matchFilesForTsConfig, preProcessXoConfig, typescriptParser, } from './utils.js';
17
17
  export const ignoredFileWarningMessage = 'File ignored because of a matching ignore pattern.';
18
+ export const noFilesFoundErrorMessage = 'No files matching the pattern were found.';
19
+ const suppressionsFileMissingErrorMessage = 'The suppressions file does not exist. Please run the command with `--suppress-all` or `--suppress-rule` to create it.';
20
+ const createErrorWithExitCode = (message, exitCode) => Object.assign(new Error(message), { exitCode });
18
21
  const createIgnoredLintResult = (filePath) => ({
19
22
  filePath,
20
23
  messages: [
@@ -34,6 +37,24 @@ const createIgnoredLintResult = (filePath) => ({
34
37
  fixableWarningCount: 0,
35
38
  usedDeprecatedRules: [],
36
39
  });
40
+ const normalizeGlobPath = (filePath) => filePath.split(path.sep).join('/');
41
+ const pathMatchesPattern = (filePath, pattern) => micromatch.isMatch(normalizeGlobPath(filePath), normalizeGlobPath(pattern), { dot: true });
42
+ const isIgnoredByPatterns = (filePath, patterns) => {
43
+ let isIgnored = false;
44
+ for (const pattern of patterns) {
45
+ if (pattern.startsWith('!')) {
46
+ if (pathMatchesPattern(filePath, pattern.slice(1))) {
47
+ isIgnored = false;
48
+ }
49
+ continue;
50
+ }
51
+ if (pathMatchesPattern(filePath, pattern)) {
52
+ isIgnored = true;
53
+ }
54
+ }
55
+ return isIgnored;
56
+ };
57
+ const isIgnoredFile = (cwd, filePath, patterns) => isIgnoredByPatterns(path.relative(cwd, filePath), patterns);
37
58
  const resolveExplicitFilePath = (cwd, glob) => {
38
59
  if (isDynamicPattern(glob)) {
39
60
  // Negated and wildcard globs are treated as regular glob filtering, not as explicit file paths that should trigger an ignored-file warning.
@@ -50,18 +71,110 @@ const resolveExplicitFilePath = (cwd, glob) => {
50
71
  }
51
72
  return undefined;
52
73
  };
53
- const getIgnoredExplicitFileResults = async (cwd, globs, eslint) => {
74
+ const getIgnoredExplicitFileResults = async (cwd, globs, eslint, discoveryIgnores = []) => {
54
75
  const explicitFilePaths = [...new Set(globs
55
76
  .map(glob => resolveExplicitFilePath(cwd, glob))
56
77
  .filter(filePath => filePath !== undefined))];
57
- const results = await Promise.all(explicitFilePaths.map(async (filePath) => await eslint.isPathIgnored(filePath) ? createIgnoredLintResult(filePath) : undefined));
78
+ const results = await Promise.all(explicitFilePaths.map(async (filePath) => {
79
+ if (isIgnoredFile(cwd, filePath, discoveryIgnores)) {
80
+ return createIgnoredLintResult(filePath);
81
+ }
82
+ return await eslint.isPathIgnored(filePath) ? createIgnoredLintResult(filePath) : undefined;
83
+ }));
58
84
  return results.filter(result => result !== undefined);
59
85
  };
86
+ const isGlobalIgnoreConfig = (config) => {
87
+ const keys = Object.keys(config);
88
+ return config.ignores !== undefined && (keys.length === 1 || (keys.length === 2 && config.name !== undefined));
89
+ };
90
+ const expandIgnoreNegationForEslint = (pattern) => {
91
+ const negatedPattern = pattern.slice(1);
92
+ const { base, isGlob } = micromatch.scan(negatedPattern, { parts: true });
93
+ const parentPath = isGlob ? base : path.posix.dirname(negatedPattern);
94
+ if (parentPath === '' || parentPath === '.') {
95
+ return [pattern];
96
+ }
97
+ const expandedPatterns = parentPath.split('/').map((_, index, segments) => `!${segments.slice(0, index + 1).join('/')}`);
98
+ expandedPatterns.push(pattern);
99
+ return expandedPatterns;
100
+ };
101
+ const expandIgnoreNegationsForEslint = (patterns) => patterns.flatMap(pattern => pattern.startsWith('!') ? expandIgnoreNegationForEslint(pattern) : [pattern]);
102
+ const expandGlobalIgnoreConfigForEslint = (config) => {
103
+ if (!isGlobalIgnoreConfig(config)) {
104
+ return config;
105
+ }
106
+ return {
107
+ ...config,
108
+ ignores: expandIgnoreNegationsForEslint(arrify(config.ignores)),
109
+ };
110
+ };
111
+ const stripDefaultIgnoreConfigs = (configs) => configs.map(configItem => {
112
+ const { ignores } = configItem;
113
+ const isDefaultIgnoreConfig = ignores !== undefined && ignores.length > 0 && ignores.every(pattern => defaultIgnores.includes(pattern));
114
+ if (!isDefaultIgnoreConfig) {
115
+ return configItem;
116
+ }
117
+ const { ignores: _ignored, ...configWithoutIgnores } = configItem;
118
+ return configWithoutIgnores;
119
+ });
120
+ const defaultIgnoreOverlapsReopenedPattern = (defaultIgnore, pattern) => {
121
+ const { base, isGlob } = micromatch.scan(pattern, { parts: true });
122
+ const patternDirname = path.posix.dirname(pattern);
123
+ const reopenedBase = isGlob ? base : (patternDirname === '' ? pattern : patternDirname);
124
+ const { base: defaultBase, isGlob: isDefaultGlob } = micromatch.scan(defaultIgnore, { parts: true });
125
+ const ignoreBase = isDefaultGlob ? defaultBase : defaultIgnore;
126
+ return micromatch.isMatch(pattern, defaultIgnore, { dot: true })
127
+ || micromatch.isMatch(defaultIgnore, pattern, { dot: true })
128
+ || ignoreBase === ''
129
+ || reopenedBase === ''
130
+ || ignoreBase.startsWith(`${reopenedBase}/`)
131
+ || reopenedBase.startsWith(`${ignoreBase}/`);
132
+ };
133
+ const getReopenedDefaultPatterns = (patterns) => patterns
134
+ .filter(pattern => pattern.startsWith('!'))
135
+ .map(pattern => pattern.slice(1))
136
+ .filter(pattern => defaultIgnores.some(defaultIgnore => defaultIgnoreOverlapsReopenedPattern(defaultIgnore, pattern)));
137
+ /**
138
+ XO only compensates for negations that reopen its built-in default ignores.
139
+ User-provided positive ignores are still used only for pruning.
140
+ The flow is:
141
+ 1. discover with `defaultIgnores + positive global ignores`
142
+ 2. if a negation reopens a built-in default ignore, run one extra pass with only that default ignore relaxed
143
+ 3. apply the real global ignore order in XO: default ignores, then config ignores, then CLI ignores
144
+ 4. let ESLint make the final ignore decision
145
+
146
+ This keeps the common "lint files XO ignores by default" behavior without trying to fully reimplement ESLint's ignore engine during discovery.
147
+ */
148
+ const discoverLintFiles = async ({ cwd, globs, positiveGlobalIgnores, discoveryIgnores, reopenedDefaultPatterns }) => {
149
+ const discoveredFiles = await globby(globs, {
150
+ ignore: [...defaultIgnores, ...positiveGlobalIgnores],
151
+ onlyFiles: true,
152
+ gitignore: true,
153
+ globalGitignore: true,
154
+ absolute: true,
155
+ dot: true,
156
+ cwd,
157
+ });
158
+ const effectiveIgnores = [...defaultIgnores, ...discoveryIgnores];
159
+ if (reopenedDefaultPatterns.length === 0) {
160
+ return discoveredFiles.filter(filePath => !isIgnoredFile(cwd, filePath, effectiveIgnores));
161
+ }
162
+ const reopenedFiles = await globby(globs, {
163
+ ignore: [
164
+ ...positiveGlobalIgnores,
165
+ ...defaultIgnores.filter(defaultIgnore => reopenedDefaultPatterns.every(pattern => !defaultIgnoreOverlapsReopenedPattern(defaultIgnore, pattern))),
166
+ ],
167
+ onlyFiles: true,
168
+ gitignore: true,
169
+ globalGitignore: true,
170
+ absolute: true,
171
+ dot: true,
172
+ cwd,
173
+ });
174
+ return [...new Set([...discoveredFiles, ...reopenedFiles])]
175
+ .filter(filePath => !isIgnoredFile(cwd, filePath, effectiveIgnores));
176
+ };
60
177
  export class Xo {
61
- /**
62
- Static helper to convert an XO config to an ESLint config to be used in `eslint.config.js`.
63
- */
64
- static xoToEslintConfig = xoToEslintConfig;
65
178
  /**
66
179
  Static helper for backwards compatibility and use in editor extensions and other tools.
67
180
  */
@@ -73,8 +186,8 @@ export class Xo {
73
186
  quiet: options.quiet,
74
187
  ts: options.ts ?? true,
75
188
  configPath: options.configPath,
189
+ suppressionsLocation: options.suppressionsLocation,
76
190
  }, {
77
- react: options.react,
78
191
  space: options.space,
79
192
  semicolon: options.semicolon,
80
193
  prettier: options.prettier,
@@ -96,8 +209,8 @@ export class Xo {
96
209
  quiet: options.quiet,
97
210
  ts: options.ts,
98
211
  configPath: options.configPath,
212
+ suppressionsLocation: options.suppressionsLocation,
99
213
  }, {
100
- react: options.react,
101
214
  space: options.space,
102
215
  semicolon: options.semicolon,
103
216
  prettier: options.prettier,
@@ -114,303 +227,116 @@ export class Xo {
114
227
  /**
115
228
  Required linter options: `cwd`, `fix`, and `filePath` (in case of `lintText`).
116
229
  */
117
- linterOptions;
118
- /**
119
- Base XO config options that allow configuration from CLI or other sources. Not to be confused with the `xoConfig` property which is the resolved XO config from the flat config AND base config.
120
- */
121
- baseXoConfig;
230
+ #linterOptions;
122
231
  /**
123
232
  File path to the ESLint cache.
124
233
  */
125
- cacheLocation;
126
- /**
127
- A re-usable ESLint instance configured with options calculated from the XO config.
128
- */
129
- eslint;
234
+ #cacheLocation;
130
235
  /**
131
236
  XO config derived from both the base config and the resolved flat config.
132
237
  */
133
- xoConfig;
238
+ #xoConfig;
134
239
  /**
135
- The ESLint config calculated from the resolved XO config.
136
- */
137
- eslintConfig;
138
- /**
139
- The flat XO config path, if there is one.
240
+ Base XO config options that allow configuration from CLI or other sources. Not to be confused with the `xoConfig` property which is the resolved XO config from the flat config AND base config.
140
241
  */
141
- flatConfigPath;
242
+ #baseXoConfig;
142
243
  /**
143
- If any user configs contain Prettier, we will need to fetch the Prettier config.
244
+ A re-usable ESLint instance configured with options calculated from the XO config.
144
245
  */
145
- prettier;
246
+ #eslint;
146
247
  /**
147
- The Prettier config if it exists and is needed.
248
+ The ESLint config calculated from the resolved XO config.
148
249
  */
149
- prettierConfig;
250
+ #eslintConfig;
150
251
  /**
151
252
  The glob pattern for TypeScript files, for which we will handle TS files and tsconfig.
152
253
 
153
254
  We expand this based on the XO config and the files glob patterns.
154
255
  */
155
- tsFilesGlob = [tsFilesGlob];
256
+ #tsFilesGlob = [tsFilesGlob];
156
257
  /**
157
258
  We use this to also add negative glob patterns in case a user overrides the parserOptions in their XO config.
158
259
  */
159
- tsFilesIgnoresGlob = [];
160
- /**
161
- Track whether ignores have been added to prevent duplicate ignore configs.
162
- */
163
- ignoresHandled = false;
260
+ #tsFilesIgnoresGlob = [];
164
261
  /**
165
262
  Store per-file configs separately from base config to prevent unbounded array growth.
166
263
  Key: file path, Value: config for that file.
167
264
  This prevents memory bloat in long-running processes (e.g., language servers).
168
265
  */
169
- fileConfigs = new Map();
266
+ #fileConfigs = new Map();
170
267
  /**
171
268
  Track virtual/stdin files that share a single tsconfig.stdin.json.
172
269
  These are handled differently from regular files.
173
270
  */
174
- virtualFiles = new Set();
271
+ #virtualFiles = new Set();
175
272
  constructor(_linterOptions, _baseXoConfig = {}) {
176
- this.linterOptions = _linterOptions;
177
- this.baseXoConfig = _baseXoConfig;
273
+ this.#linterOptions = _linterOptions;
274
+ this.#baseXoConfig = _baseXoConfig;
178
275
  // Fix relative cwd paths
179
- if (!path.isAbsolute(this.linterOptions.cwd)) {
180
- this.linterOptions.cwd = path.resolve(process.cwd(), this.linterOptions.cwd);
276
+ if (!path.isAbsolute(this.#linterOptions.cwd)) {
277
+ this.#linterOptions.cwd = path.resolve(process.cwd(), this.#linterOptions.cwd);
181
278
  }
182
279
  try {
183
- this.linterOptions.cwd = syncFs.realpathSync.native(this.linterOptions.cwd);
280
+ this.#linterOptions.cwd = syncFs.realpathSync.native(this.#linterOptions.cwd);
184
281
  }
185
282
  catch {
186
283
  // Ignore invalid paths here; the caller will handle errors later.
187
284
  }
188
285
  const backupCacheLocation = path.join(os.tmpdir(), cacheDirName);
189
- this.cacheLocation = findCacheDirectory({ name: cacheDirName, cwd: this.linterOptions.cwd }) ?? backupCacheLocation;
190
- }
191
- /**
192
- Sets the XO config on the XO instance.
193
-
194
- @private
195
- */
196
- async setXoConfig() {
197
- if (this.xoConfig) {
198
- return;
199
- }
200
- const { flatOptions, flatConfigPath } = await resolveXoConfig({
201
- ...this.linterOptions,
202
- });
203
- const { config, tsFilesGlob, tsFilesIgnoresGlob } = preProcessXoConfig([
204
- this.baseXoConfig,
205
- ...flatOptions,
206
- ]);
207
- this.xoConfig = config;
208
- this.tsFilesGlob.push(...tsFilesGlob);
209
- this.tsFilesIgnoresGlob.push(...tsFilesIgnoresGlob);
210
- this.prettier = this.xoConfig.some(config => config.prettier);
211
- this.prettierConfig = await prettier.resolveConfig(flatConfigPath, { editorconfig: true }) ?? {};
212
- this.flatConfigPath = flatConfigPath;
213
- }
214
- /**
215
- Sets the ESLint config on the XO instance.
216
-
217
- @private
218
- */
219
- setEslintConfig() {
220
- if (!this.xoConfig) {
221
- throw new Error('"Xo.setEslintConfig" failed');
222
- }
223
- // Combine base config with per-file configs from Map
224
- // Deduplicate configs since multiple files can share the same config object
225
- const uniqueFileConfigs = [...new Set(this.fileConfigs.values())];
226
- const allConfigs = [...this.xoConfig, ...uniqueFileConfigs];
227
- // Always regenerate to support instance reuse with new files
228
- this.eslintConfig = xoToEslintConfig(allConfigs, { prettierOptions: this.prettierConfig });
229
- }
230
- /**
231
- Sets the ignores on the XO instance.
232
-
233
- @private
234
- */
235
- setIgnores() {
236
- if (this.ignoresHandled || !this.baseXoConfig.ignores) {
237
- return;
238
- }
239
- let ignores = [];
240
- if (typeof this.baseXoConfig.ignores === 'string') {
241
- ignores = arrify(this.baseXoConfig.ignores);
242
- }
243
- else if (Array.isArray(this.baseXoConfig.ignores)) {
244
- ignores = this.baseXoConfig.ignores;
245
- }
246
- if (!this.xoConfig) {
247
- throw new Error('"Xo.setIgnores" failed');
248
- }
249
- if (ignores.length === 0) {
250
- return;
251
- }
252
- this.xoConfig.push({ ignores });
253
- this.ignoresHandled = true;
254
- }
255
- /**
256
- Ensures the cache directory exists. This needs to run once before both tsconfig handling and running ESLint occur.
257
-
258
- @private
259
- */
260
- async ensureCacheDirectory() {
261
- try {
262
- const cacheStats = await fs.stat(this.cacheLocation);
263
- // If file, re-create as directory
264
- if (cacheStats.isFile()) {
265
- await fs.rm(this.cacheLocation, { recursive: true, force: true });
266
- await fs.mkdir(this.cacheLocation, { recursive: true });
267
- }
268
- }
269
- catch {
270
- // If not exists, create the directory
271
- await fs.mkdir(this.cacheLocation, { recursive: true });
272
- }
273
- }
274
- /**
275
- Checks every TS file to ensure its included in the tsconfig and any that are not included are added to an in-memory TypeScript Program for type aware linting.
276
-
277
- @param files - The TypeScript files being linted.
278
- */
279
- async handleUnincludedTsFiles(files) {
280
- if (!this.linterOptions.ts || !files || files.length === 0) {
281
- return;
282
- }
283
- // Get ALL TypeScript files being linted (both new and previously handled)
284
- const allTsFiles = matchFilesForTsConfig(this.linterOptions.cwd, files, this.tsFilesGlob, this.tsFilesIgnoresGlob);
285
- if (allTsFiles.length === 0) {
286
- this.fileConfigs.clear();
287
- if (this.virtualFiles.size > 0) {
288
- await this.addVirtualFilesToConfig([]);
289
- }
290
- return;
291
- }
292
- const { program, existingFiles, virtualFiles } = handleTsconfig({
293
- files: allTsFiles,
294
- cwd: this.linterOptions.cwd,
295
- cacheLocation: this.cacheLocation,
296
- });
297
- this.fileConfigs.clear();
298
- if (existingFiles.length > 0) {
299
- this.addExistingFilesToConfig(existingFiles, program);
300
- }
301
- await this.addVirtualFilesToConfig(virtualFiles);
286
+ this.#cacheLocation = findCacheDirectory({ name: cacheDirName, cwd: this.#linterOptions.cwd }) ?? backupCacheLocation;
302
287
  }
303
288
  /**
304
- Initializes the ESLint instance on the XO instance.
289
+ Initializes the ESLint flat config on the XO instance.
305
290
  */
306
- async initEslint(files) {
291
+ async prepareEslintConfig(files, cliIgnores = arrify(this.#baseXoConfig.ignores), stripDefaultIgnores = false) {
307
292
  await this.setXoConfig();
308
- this.setIgnores();
309
293
  await this.ensureCacheDirectory();
310
294
  await this.handleUnincludedTsFiles(files);
311
- this.setEslintConfig();
312
- if (!this.xoConfig) {
313
- throw new Error('"Xo.initEslint" failed');
314
- }
315
- const eslintOptions = {
316
- cwd: this.linterOptions.cwd,
317
- overrideConfig: this.eslintConfig,
318
- overrideConfigFile: true,
319
- globInputPaths: false,
320
- warnIgnored: false,
321
- cache: true,
322
- cacheLocation: this.cacheLocation,
323
- cacheStrategy: 'content',
324
- fix: this.linterOptions.fix,
325
- };
326
- // Always create new instance to support reuse with updated config
327
- // ESLint's file-based cache (cacheLocation) persists across instances
328
- this.eslint = new ESLint(eslintOptions);
329
- }
330
- /**
331
- Lints the files on the XO instance.
332
-
333
- @param globs - Glob pattern to pass to `globby`.
334
- @throws Error
335
- */
336
- async lintFiles(globs) {
337
- if (!globs || (Array.isArray(globs) && globs.length === 0)) {
338
- globs = `**/*.{${allExtensions.join(',')}}`;
339
- }
340
- globs = arrify(globs);
341
- const files = await globby(globs, {
342
- // Merge in command line ignores
343
- ignore: [...defaultIgnores, ...arrify(this.baseXoConfig.ignores)],
344
- onlyFiles: true,
345
- gitignore: true,
346
- absolute: true,
347
- dot: true,
348
- cwd: this.linterOptions.cwd,
349
- });
350
- await this.initEslint(files);
351
- if (!this.eslint) {
352
- throw new Error('Failed to initialize ESLint');
295
+ this.setEslintConfig(cliIgnores, stripDefaultIgnores);
296
+ if (!this.#eslintConfig) {
297
+ throw new Error('"Xo.prepareEslintConfig" failed');
353
298
  }
354
- const { eslint } = this;
355
- const ignoredResults = await getIgnoredExplicitFileResults(this.linterOptions.cwd, globs, eslint);
356
- if (files.length === 0) {
357
- return this.processReport(ignoredResults);
358
- }
359
- const results = await eslint.lintFiles(files);
360
- const rulesMeta = eslint.getRulesMetaForResults(results);
361
- // No overlap: `warnIgnored: false` makes ESLint silently drop ignored files from `results`.
362
- return this.processReport([...results, ...ignoredResults], { rulesMeta });
299
+ return this.#eslintConfig;
363
300
  }
364
- /**
365
- Lints the text on the XO instance.
366
- */
367
- async lintText(code, lintTextOptions) {
368
- const { filePath, warnIgnored } = lintTextOptions;
369
- await this.initEslint([filePath]);
370
- if (!this.eslint) {
371
- throw new Error('Failed to initialize ESLint');
372
- }
373
- const results = await this.eslint?.lintText(code, {
374
- filePath,
375
- warnIgnored,
301
+ async discoverFiles(globs) {
302
+ await this.setXoConfig();
303
+ const cliIgnores = arrify(this.#baseXoConfig.ignores);
304
+ const configIgnores = (this.#xoConfig ?? []).slice(1)
305
+ .flatMap(config => isGlobalIgnoreConfig(config) ? arrify(config.ignores) : []);
306
+ const discoveryIgnores = [...configIgnores, ...cliIgnores];
307
+ const positiveGlobalIgnores = discoveryIgnores.filter(pattern => !pattern.startsWith('!'));
308
+ const reopenedDefaultPatterns = getReopenedDefaultPatterns(discoveryIgnores);
309
+ const files = await discoverLintFiles({
310
+ cwd: this.#linterOptions.cwd,
311
+ globs,
312
+ positiveGlobalIgnores,
313
+ discoveryIgnores,
314
+ reopenedDefaultPatterns,
376
315
  });
377
- const rulesMeta = this.eslint.getRulesMetaForResults(results ?? []);
378
- return this.processReport(results ?? [], { rulesMeta });
379
- }
380
- async calculateConfigForFile(filePath) {
381
- await this.initEslint([filePath]);
382
- if (!this.eslint) {
383
- throw new Error('Failed to initialize ESLint');
384
- }
385
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
386
- return this.eslint.calculateConfigForFile(filePath);
387
- }
388
- async getFormatter(name) {
389
- await this.initEslint();
390
- if (!this.eslint) {
391
- throw new Error('Failed to initialize ESLint');
392
- }
393
- return this.eslint.loadFormatter(name);
316
+ return {
317
+ cliIgnores,
318
+ discoveryIgnores,
319
+ files,
320
+ };
394
321
  }
395
322
  /**
396
323
  Add virtual files to the config with a tsconfig approach.
397
324
  */
398
325
  async addVirtualFilesToConfig(files) {
399
- if (!this.xoConfig) {
326
+ if (!this.#xoConfig) {
400
327
  return;
401
328
  }
402
329
  try {
403
330
  const nextVirtualFiles = new Set(files);
404
- const tsconfigPath = path.join(this.cacheLocation, 'tsconfig.stdin.json');
405
- const configIndex = this.xoConfig.findIndex(configItem => {
331
+ const tsconfigPath = path.join(this.#cacheLocation, 'tsconfig.stdin.json');
332
+ const configIndex = this.#xoConfig.findIndex(configItem => {
406
333
  const { languageOptions } = configItem;
407
- const parserOptionsCandidate = languageOptions?.parserOptions;
408
- const parserOptions = parserOptionsCandidate;
334
+ const parserOptions = languageOptions?.['parserOptions'];
409
335
  return parserOptions?.project === tsconfigPath;
410
336
  });
411
337
  if (nextVirtualFiles.size > 0) {
412
338
  const filesArray = [...nextVirtualFiles];
413
- const relativeFiles = filesArray.map(file => path.relative(this.linterOptions.cwd, file));
339
+ const relativeFiles = filesArray.map(file => path.relative(this.#linterOptions.cwd, file));
414
340
  const tsconfigContent = {
415
341
  compilerOptions: {
416
342
  ...tsconfigDefaults.compilerOptions,
@@ -426,9 +352,9 @@ export class Xo {
426
352
  const parserOptions = {
427
353
  projectService: false,
428
354
  project: tsconfigPath,
429
- tsconfigRootDir: this.linterOptions.cwd,
355
+ tsconfigRootDir: this.#linterOptions.cwd,
430
356
  };
431
- this.xoConfig.push({
357
+ this.#xoConfig.push({
432
358
  files: relativeFiles,
433
359
  languageOptions: {
434
360
  parser: typescriptParser,
@@ -437,22 +363,22 @@ export class Xo {
437
363
  });
438
364
  }
439
365
  else {
440
- const existingConfig = this.xoConfig[configIndex];
441
- this.xoConfig[configIndex] = {
366
+ const existingConfig = this.#xoConfig[configIndex];
367
+ this.#xoConfig[configIndex] = {
442
368
  ...existingConfig,
443
369
  files: relativeFiles,
444
370
  };
445
371
  }
446
- this.virtualFiles.clear();
372
+ this.#virtualFiles.clear();
447
373
  for (const file of nextVirtualFiles) {
448
- this.virtualFiles.add(file);
374
+ this.#virtualFiles.add(file);
449
375
  }
450
376
  return;
451
377
  }
452
378
  if (configIndex >= 0) {
453
- this.xoConfig.splice(configIndex, 1);
379
+ this.#xoConfig.splice(configIndex, 1);
454
380
  }
455
- this.virtualFiles.clear();
381
+ this.#virtualFiles.clear();
456
382
  await fs.rm(tsconfigPath, { force: true });
457
383
  }
458
384
  catch (error) {
@@ -463,7 +389,7 @@ export class Xo {
463
389
  Add existing files to the config with an in-memory TypeScript Program.
464
390
  */
465
391
  addExistingFilesToConfig(files, program) {
466
- if (!this.xoConfig || files.length === 0) {
392
+ if (!this.#xoConfig || files.length === 0) {
467
393
  return;
468
394
  }
469
395
  const parserOptions = {
@@ -474,7 +400,7 @@ export class Xo {
474
400
  parserOptions.programs = [program];
475
401
  }
476
402
  const config = {
477
- files: files.map(file => path.relative(this.linterOptions.cwd, file)),
403
+ files: files.map(file => path.relative(this.#linterOptions.cwd, file)),
478
404
  languageOptions: {
479
405
  parser: typescriptParser,
480
406
  parserOptions,
@@ -485,11 +411,11 @@ export class Xo {
485
411
  // The config is immutable after creation, so sharing is safe.
486
412
  // Deduplication happens in setEslintConfig() via Set to avoid duplicate configs in the final array.
487
413
  for (const file of files) {
488
- this.fileConfigs.set(file, config);
414
+ this.#fileConfigs.set(file, config);
489
415
  }
490
416
  }
491
417
  processReport(report, { rulesMeta = {} } = {}) {
492
- if (this.linterOptions.quiet) {
418
+ if (this.#linterOptions.quiet) {
493
419
  report = ESLint.getErrorResults(report);
494
420
  }
495
421
  const result = {
@@ -502,11 +428,10 @@ export class Xo {
502
428
  const rules = [];
503
429
  for (const { usedDeprecatedRules } of report) {
504
430
  for (const rule of usedDeprecatedRules) {
505
- if (seenRules.has(rule.ruleId)) {
506
- continue;
431
+ if (!seenRules.has(rule.ruleId)) {
432
+ seenRules.add(rule.ruleId);
433
+ rules.push(rule);
507
434
  }
508
- seenRules.add(rule.ruleId);
509
- rules.push(rule);
510
435
  }
511
436
  }
512
437
  return rules;
@@ -528,6 +453,203 @@ export class Xo {
528
453
  }
529
454
  return statistics;
530
455
  }
456
+ /**
457
+ Throws if a suppressions location was provided but the file does not exist.
458
+ */
459
+ async assertSuppressionsFileExists() {
460
+ if (this.#linterOptions.suppressionsLocation === undefined) {
461
+ return;
462
+ }
463
+ const suppressionsFilePath = path.resolve(this.#linterOptions.cwd, this.#linterOptions.suppressionsLocation);
464
+ try {
465
+ await fs.access(suppressionsFilePath);
466
+ }
467
+ catch {
468
+ throw createErrorWithExitCode(suppressionsFileMissingErrorMessage, 2);
469
+ }
470
+ }
471
+ /**
472
+ Sets the XO config on the XO instance.
473
+ */
474
+ async setXoConfig() {
475
+ if (this.#xoConfig) {
476
+ return;
477
+ }
478
+ const { flatOptions } = await resolveXoConfig({
479
+ ...this.#linterOptions,
480
+ });
481
+ const { config: xoConfig, tsFilesGlob: tsGlob, tsFilesIgnoresGlob } = preProcessXoConfig([
482
+ this.#baseXoConfig,
483
+ ...flatOptions,
484
+ ]);
485
+ this.#xoConfig = xoConfig;
486
+ this.#tsFilesGlob.push(...tsGlob);
487
+ this.#tsFilesIgnoresGlob.push(...tsFilesIgnoresGlob);
488
+ }
489
+ setEslintConfig(cliIgnores = arrify(this.#baseXoConfig.ignores), stripDefaultIgnores = false) {
490
+ if (!this.#xoConfig) {
491
+ throw new Error('"Xo.setEslintConfig" failed');
492
+ }
493
+ // Combine base config with per-file configs from Map
494
+ // Deduplicate configs since multiple files can share the same config object
495
+ const [baseConfig = {}, ...resolvedConfigs] = this.#xoConfig;
496
+ const { ignores, ...configWithoutCliIgnores } = baseConfig;
497
+ const expandedResolvedConfigs = resolvedConfigs.map(config => expandGlobalIgnoreConfigForEslint(config));
498
+ const uniqueFileConfigs = [...new Set(this.#fileConfigs.values())];
499
+ const cliIgnoreConfig = cliIgnores.length > 0 ? [{ ignores: expandIgnoreNegationsForEslint(cliIgnores) }] : [];
500
+ const allConfigs = [configWithoutCliIgnores, ...expandedResolvedConfigs, ...cliIgnoreConfig, ...uniqueFileConfigs];
501
+ // Always regenerate to support instance reuse with new files
502
+ this.#eslintConfig = xoToEslintConfig(allConfigs);
503
+ if (stripDefaultIgnores) {
504
+ this.#eslintConfig = stripDefaultIgnoreConfigs(this.#eslintConfig);
505
+ }
506
+ }
507
+ /**
508
+ Ensures the cache directory exists. This needs to run once before both tsconfig handling and running ESLint occur.
509
+ */
510
+ async ensureCacheDirectory() {
511
+ try {
512
+ const cacheStats = await fs.stat(this.#cacheLocation);
513
+ // If file, re-create as directory
514
+ if (cacheStats.isFile()) {
515
+ await fs.rm(this.#cacheLocation, { recursive: true, force: true });
516
+ await fs.mkdir(this.#cacheLocation, { recursive: true });
517
+ }
518
+ }
519
+ catch (error) {
520
+ // If not exists, create the directory. Rethrow any other error (for example, permission issues).
521
+ if (error.code !== 'ENOENT') {
522
+ throw error;
523
+ }
524
+ await fs.mkdir(this.#cacheLocation, { recursive: true });
525
+ }
526
+ }
527
+ /**
528
+ Checks every TS file to ensure its included in the tsconfig and any that are not included are added to an in-memory TypeScript Program for type aware linting.
529
+
530
+ @param files - The TypeScript files being linted.
531
+ */
532
+ async handleUnincludedTsFiles(files) {
533
+ if (!this.#linterOptions.ts || !files || files.length === 0) {
534
+ return;
535
+ }
536
+ // Get ALL TypeScript files being linted (both new and previously handled)
537
+ const allTsFiles = matchFilesForTsConfig(this.#linterOptions.cwd, files, this.#tsFilesGlob, this.#tsFilesIgnoresGlob);
538
+ if (allTsFiles.length === 0) {
539
+ this.#fileConfigs.clear();
540
+ if (this.#virtualFiles.size > 0) {
541
+ await this.addVirtualFilesToConfig([]);
542
+ }
543
+ return;
544
+ }
545
+ const { program, existingFiles, virtualFiles } = handleTsconfig({
546
+ files: allTsFiles,
547
+ cwd: this.#linterOptions.cwd,
548
+ cacheLocation: this.#cacheLocation,
549
+ });
550
+ this.#fileConfigs.clear();
551
+ if (existingFiles.length > 0) {
552
+ this.addExistingFilesToConfig(existingFiles, program);
553
+ }
554
+ await this.addVirtualFilesToConfig(virtualFiles);
555
+ }
556
+ /**
557
+ Initializes the ESLint instance on the XO instance.
558
+ */
559
+ async initEslint(files, cliIgnores = arrify(this.#baseXoConfig.ignores), stripDefaultIgnores = false) {
560
+ await this.prepareEslintConfig(files, cliIgnores, stripDefaultIgnores);
561
+ if (!this.#xoConfig) {
562
+ throw new Error('"Xo.initEslint" failed');
563
+ }
564
+ const eslintOptions = {
565
+ cwd: this.#linterOptions.cwd,
566
+ overrideConfig: this.#eslintConfig,
567
+ overrideConfigFile: true,
568
+ globInputPaths: false,
569
+ warnIgnored: false,
570
+ cache: true,
571
+ cacheLocation: this.#cacheLocation,
572
+ cacheStrategy: 'content',
573
+ fix: this.#linterOptions.fix,
574
+ applySuppressions: true,
575
+ suppressionsLocation: this.#linterOptions.suppressionsLocation,
576
+ };
577
+ // Always create new instance to support reuse with updated config
578
+ // ESLint's file-based cache (cacheLocation) persists across instances
579
+ this.#eslint = new ESLint(eslintOptions);
580
+ }
581
+ /**
582
+ Create an ESLint flat config for editor integrations using the same XO pipeline as the CLI.
583
+ */
584
+ async getProjectEslintConfig() {
585
+ const { cliIgnores, files } = await this.discoverFiles([`**/*.{${allExtensions.join(',')}}`]);
586
+ return this.prepareEslintConfig(files, cliIgnores);
587
+ }
588
+ /**
589
+ Lints the files on the XO instance.
590
+
591
+ @param globs - Glob pattern to pass to `globby`.
592
+ @throws Error
593
+ */
594
+ async lintFiles(globs) {
595
+ if (globs === undefined || (Array.isArray(globs) && globs.length === 0)) {
596
+ globs = `**/*.{${allExtensions.join(',')}}`;
597
+ }
598
+ globs = arrify(globs);
599
+ // If any explicitly provided pattern is non-dynamic (a literal file path), throw when no files are found.
600
+ // Dynamic glob patterns matching nothing is acceptable — the project may simply have no matching files yet.
601
+ // The default glob substitution above is always dynamic, so this is false when no globs were provided.
602
+ const hasExplicitFilePaths = globs.some(glob => !isDynamicPattern(glob));
603
+ const { cliIgnores, discoveryIgnores, files } = await this.discoverFiles(globs);
604
+ await this.assertSuppressionsFileExists();
605
+ await this.initEslint(files, cliIgnores, true);
606
+ if (!this.#eslint) {
607
+ throw new Error('Failed to initialize ESLint');
608
+ }
609
+ const eslint = this.#eslint;
610
+ const ignoredResults = await getIgnoredExplicitFileResults(this.#linterOptions.cwd, globs, eslint, [...defaultIgnores, ...discoveryIgnores]);
611
+ if (files.length === 0) {
612
+ if (hasExplicitFilePaths && ignoredResults.length === 0) {
613
+ throw new Error(noFilesFoundErrorMessage);
614
+ }
615
+ return this.processReport(ignoredResults);
616
+ }
617
+ const results = await eslint.lintFiles(files);
618
+ const rulesMeta = eslint.getRulesMetaForResults(results);
619
+ // No overlap: `warnIgnored: false` makes ESLint silently drop ignored files from `results`.
620
+ return this.processReport([...results, ...ignoredResults], { rulesMeta });
621
+ }
622
+ /**
623
+ Lints the text on the XO instance.
624
+ */
625
+ async lintText(code, lintTextOptions) {
626
+ const { filePath, warnIgnored: shouldWarnIgnored } = lintTextOptions;
627
+ await this.assertSuppressionsFileExists();
628
+ await this.initEslint([filePath]);
629
+ if (!this.#eslint) {
630
+ throw new Error('Failed to initialize ESLint');
631
+ }
632
+ const results = await this.#eslint.lintText(code, {
633
+ filePath,
634
+ warnIgnored: shouldWarnIgnored,
635
+ });
636
+ const rulesMeta = this.#eslint.getRulesMetaForResults(results);
637
+ return this.processReport(results, { rulesMeta });
638
+ }
639
+ async calculateConfigForFile(filePath) {
640
+ await this.initEslint([filePath]);
641
+ if (!this.#eslint) {
642
+ throw new Error('Failed to initialize ESLint');
643
+ }
644
+ return this.#eslint.calculateConfigForFile(filePath);
645
+ }
646
+ async getFormatter(name) {
647
+ await this.initEslint();
648
+ if (!this.#eslint) {
649
+ throw new Error('Failed to initialize ESLint');
650
+ }
651
+ return this.#eslint.loadFormatter(name);
652
+ }
531
653
  }
532
654
  export default Xo;
533
655
  //# sourceMappingURL=xo.js.map