xo 1.2.3 → 2.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.
@@ -3,50 +3,61 @@ import pluginUnicorn from 'eslint-plugin-unicorn';
3
3
  import pluginImport from 'eslint-plugin-import-x';
4
4
  import pluginN from 'eslint-plugin-n';
5
5
  import pluginComments from '@eslint-community/eslint-plugin-eslint-comments';
6
- import pluginPromise from 'eslint-plugin-promise';
7
- import pluginNoUseExtendNative from 'eslint-plugin-no-use-extend-native';
6
+ /// import pluginPromise from 'eslint-plugin-promise';
8
7
  import configXoTypescript from 'eslint-config-xo-typescript';
9
- import stylisticPlugin from '@stylistic/eslint-plugin';
10
8
  import globals from 'globals';
9
+ import { fixupPluginRules } from '@eslint/compat';
11
10
  import { defaultIgnores, tsExtensions, tsFilesGlob, allFilesGlob, jsExtensions, allExtensions, } from './constants.js';
12
- if (Array.isArray(pluginAva?.configs?.['recommended'])) {
13
- throw new TypeError('Invalid eslint-plugin-ava');
14
- }
15
- if (!configXoTypescript[1]) {
11
+ import noUseExtendNativeRule from './rules/no-use-extend-native.js';
12
+ if (!configXoTypescript[4]) {
16
13
  throw new Error('Invalid eslint-config-xo-typescript');
17
14
  }
15
+ const baseLanguageOptions = configXoTypescript[0]?.languageOptions;
16
+ const baseParserOptions = baseLanguageOptions?.parserOptions ?? {};
17
+ const typescriptLanguageOptions = (configXoTypescript[4]?.languageOptions ?? {});
18
+ const typescriptParserOptions = typescriptLanguageOptions.parserOptions ?? {};
19
+ const pluginNoUseExtendNative = {
20
+ rules: {
21
+ 'no-use-extend-native': noUseExtendNativeRule,
22
+ },
23
+ };
24
+ // TODO: Remove `fixupPluginRules` wrapping when these plugins support ESLint 10 natively.
25
+ const fixedUpBasePlugins = Object.fromEntries(Object.entries(configXoTypescript[0]?.plugins ?? {}).map(([key, plugin]) => [key, fixupPluginRules(plugin)]));
26
+ const fixedUpTypescriptPlugins = Object.fromEntries(Object.entries(configXoTypescript[4]?.plugins ?? {}).map(([key, plugin]) => [key, fixupPluginRules(plugin)]));
18
27
  /**
19
28
  The base config that XO builds on top of from user options.
20
29
  */
21
30
  export const config = [
22
31
  {
23
- name: 'XO Default Ignores',
32
+ name: 'xo/ignores',
24
33
  ignores: defaultIgnores,
25
34
  },
35
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
36
+ ...pluginAva.configs['recommended'],
26
37
  {
27
- name: 'XO',
28
- files: [
29
- allFilesGlob,
30
- ],
38
+ name: 'xo/base',
39
+ files: [allFilesGlob],
31
40
  plugins: {
41
+ ...fixedUpBasePlugins,
42
+ ...fixedUpTypescriptPlugins,
32
43
  'no-use-extend-native': pluginNoUseExtendNative,
33
44
  ava: pluginAva,
34
45
  unicorn: pluginUnicorn,
35
46
  'import-x': pluginImport,
36
- n: pluginN,
37
47
  '@eslint-community/eslint-comments': pluginComments,
38
- promise: pluginPromise,
39
- '@stylistic': stylisticPlugin, // eslint-disable-line @typescript-eslint/naming-convention
48
+ // TODO: Remove `fixupPluginRules` wrapping when these plugins support ESLint 10 natively.
49
+ n: fixupPluginRules(pluginN),
50
+ /// promise: fixupPluginRules(pluginPromise),
40
51
  },
41
52
  languageOptions: {
42
53
  globals: {
43
54
  ...globals.es2021,
44
55
  ...globals.node,
45
56
  },
46
- ecmaVersion: configXoTypescript[0]?.languageOptions?.ecmaVersion,
47
- sourceType: configXoTypescript[0]?.languageOptions?.sourceType,
57
+ ecmaVersion: baseLanguageOptions?.ecmaVersion,
58
+ sourceType: baseLanguageOptions?.sourceType,
48
59
  parserOptions: {
49
- ...configXoTypescript[0]?.languageOptions?.parserOptions,
60
+ ...baseParserOptions,
50
61
  },
51
62
  },
52
63
  settings: {
@@ -71,7 +82,6 @@ export const config = [
71
82
  These are the base rules that are always applied to all js and ts file types
72
83
  */
73
84
  rules: {
74
- ...pluginAva?.configs?.['recommended']?.rules,
75
85
  ...pluginUnicorn.configs?.recommended?.rules,
76
86
  'no-use-extend-native/no-use-extend-native': 'error',
77
87
  // TODO: Remove this override at some point.
@@ -203,23 +213,24 @@ export const config = [
203
213
  'unicorn/no-useless-undefined': 'off',
204
214
  // TODO: Temporarily disabled as the rule is buggy.
205
215
  'function-call-argument-newline': 'off',
206
- 'promise/param-names': 'error',
207
- 'promise/no-return-wrap': [
208
- 'error',
209
- {
210
- allowReject: true,
211
- },
212
- ],
213
- 'promise/no-new-statics': 'error',
214
- 'promise/no-return-in-finally': 'error',
215
- 'promise/prefer-await-to-then': [
216
- 'error',
217
- {
218
- strict: true,
219
- },
220
- ],
221
- 'promise/prefer-catch': 'error',
222
- 'promise/valid-params': 'error',
216
+ // Commented out because it's not ready for ESLint 10.
217
+ // 'promise/param-names': 'error',
218
+ // 'promise/no-return-wrap': [
219
+ // 'error',
220
+ // {
221
+ // allowReject: true,
222
+ // },
223
+ // ],
224
+ // 'promise/no-new-statics': 'error',
225
+ // 'promise/no-return-in-finally': 'error',
226
+ // 'promise/prefer-await-to-then': [
227
+ // 'error',
228
+ // {
229
+ // strict: true,
230
+ // },
231
+ // ],
232
+ // 'promise/prefer-catch': 'error',
233
+ // 'promise/valid-params': 'error',
223
234
  'import-x/default': 'error',
224
235
  'import-x/export': 'error',
225
236
  'import-x/extensions': [
@@ -362,17 +373,22 @@ export const config = [
362
373
  },
363
374
  },
364
375
  {
365
- name: 'Xo TypeScript',
366
- plugins: configXoTypescript[1]?.plugins,
376
+ name: 'xo/typescript',
377
+ plugins: fixedUpTypescriptPlugins,
367
378
  files: [tsFilesGlob],
368
379
  languageOptions: {
369
- ...configXoTypescript[1]?.languageOptions,
380
+ ...typescriptLanguageOptions,
381
+ parserOptions: {
382
+ ...typescriptParserOptions,
383
+ // This needs to be explicitly set to `true`
384
+ projectService: true,
385
+ },
370
386
  },
371
387
  /**
372
388
  This turns on rules in `typescript-eslint`` and turns off rules from ESLint that conflict.
373
389
  */
374
390
  rules: {
375
- ...configXoTypescript[1]?.rules,
391
+ ...configXoTypescript[4]?.rules,
376
392
  'unicorn/import-style': 'off',
377
393
  'n/file-extension-in-import': 'off',
378
394
  // Disabled because of https://github.com/benmosher/eslint-plugin-import-x/issues/1590
@@ -384,7 +400,7 @@ export const config = [
384
400
  'import-x/named': 'off',
385
401
  },
386
402
  },
387
- ...configXoTypescript.slice(2),
403
+ ...configXoTypescript.slice(5),
388
404
  {
389
405
  files: ['xo.config.{js,ts}'],
390
406
  rules: {
@@ -1,15 +1,18 @@
1
+ import ts from 'typescript';
1
2
  /**
2
3
  This function checks if the files are matched by the tsconfig include, exclude, and it returns the unmatched files.
3
4
 
4
- If no tsconfig is found, it will create a fallback tsconfig file in the `node_modules/.cache/xo` directory.
5
+ If no tsconfig is found, it will create an in-memory TypeScript Program for type-aware linting.
5
6
 
6
7
  @param options
7
- @returns The unmatched files.
8
+ @returns The unmatched files and an in-memory TypeScript Program.
8
9
  */
9
- export declare function handleTsconfig({ cwd, files }: {
10
- cwd: string;
10
+ export declare function handleTsconfig({ files, cwd, cacheLocation }: {
11
11
  files: string[];
12
- }): Promise<{
13
- unincludedFiles: string[];
14
- fallbackTsConfigPath: string;
15
- }>;
12
+ cwd: string;
13
+ cacheLocation?: string;
14
+ }): {
15
+ existingFiles: string[];
16
+ virtualFiles: string[];
17
+ program: ts.Program | undefined;
18
+ };
@@ -1,39 +1,113 @@
1
1
  import path from 'node:path';
2
- import fs from 'node:fs/promises';
2
+ import fs from 'node:fs';
3
+ import ts from 'typescript';
3
4
  import { getTsconfig, createFilesMatcher } from 'get-tsconfig';
4
- import { tsconfigDefaults as tsConfig, cacheDirName } from './constants.js';
5
+ import { tsconfigDefaults } from './constants.js';
6
+ const createInMemoryProgram = (files, cwd) => {
7
+ if (files.length === 0) {
8
+ return undefined;
9
+ }
10
+ try {
11
+ const compilerOptions = getFallbackCompilerOptions(cwd);
12
+ const program = ts.createProgram(files, { ...compilerOptions });
13
+ Object.defineProperty(program, 'toJSON', {
14
+ value: () => ({
15
+ __type: 'TypeScriptProgram',
16
+ files: files.map(file => path.relative(cwd, file)),
17
+ }),
18
+ configurable: true,
19
+ });
20
+ return program;
21
+ }
22
+ catch (error) {
23
+ console.warn('XO: Failed to create TypeScript Program for type-aware linting. Continuing without type information for unincluded files.', error instanceof Error ? error.message : String(error));
24
+ return undefined;
25
+ }
26
+ };
27
+ const fallbackCompilerOptionsCache = new Map();
28
+ const getFallbackCompilerOptions = (cwd) => {
29
+ const cacheKey = path.resolve(cwd);
30
+ const cached = fallbackCompilerOptionsCache.get(cacheKey);
31
+ if (cached) {
32
+ return cached;
33
+ }
34
+ const compilerOptionsResult = ts.convertCompilerOptionsFromJson(tsconfigDefaults.compilerOptions ?? {}, cacheKey);
35
+ if (compilerOptionsResult.errors.length > 0) {
36
+ throw new Error('XO: Invalid default TypeScript compiler options');
37
+ }
38
+ const compilerOptions = {
39
+ ...compilerOptionsResult.options,
40
+ esModuleInterop: true,
41
+ resolveJsonModules: true,
42
+ allowJs: true,
43
+ skipLibCheck: true,
44
+ skipDefaultLibCheck: true,
45
+ };
46
+ fallbackCompilerOptionsCache.set(cacheKey, compilerOptions);
47
+ return compilerOptions;
48
+ };
5
49
  /**
6
50
  This function checks if the files are matched by the tsconfig include, exclude, and it returns the unmatched files.
7
51
 
8
- If no tsconfig is found, it will create a fallback tsconfig file in the `node_modules/.cache/xo` directory.
52
+ If no tsconfig is found, it will create an in-memory TypeScript Program for type-aware linting.
9
53
 
10
54
  @param options
11
- @returns The unmatched files.
55
+ @returns The unmatched files and an in-memory TypeScript Program.
12
56
  */
13
- export async function handleTsconfig({ cwd, files }) {
57
+ export function handleTsconfig({ files, cwd, cacheLocation }) {
14
58
  const unincludedFiles = [];
59
+ const filesMatcherCache = new Map();
15
60
  for (const filePath of files) {
16
61
  const result = getTsconfig(filePath);
17
62
  if (!result) {
18
63
  unincludedFiles.push(filePath);
19
64
  continue;
20
65
  }
21
- const filesMatcher = createFilesMatcher(result);
66
+ const cacheKey = result.path ? path.resolve(result.path) : filePath;
67
+ let filesMatcher = filesMatcherCache.get(cacheKey);
68
+ if (!filesMatcher) {
69
+ filesMatcher = createFilesMatcher(result);
70
+ filesMatcherCache.set(cacheKey, filesMatcher);
71
+ }
22
72
  if (filesMatcher(filePath)) {
23
73
  continue;
24
74
  }
25
75
  unincludedFiles.push(filePath);
26
76
  }
27
- const fallbackTsConfigPath = path.join(cwd, 'node_modules', '.cache', cacheDirName, 'tsconfig.xo.json');
28
- tsConfig.files = unincludedFiles;
29
- if (unincludedFiles.length > 0) {
30
- try {
31
- await fs.writeFile(fallbackTsConfigPath, JSON.stringify(tsConfig, null, 2));
77
+ if (unincludedFiles.length === 0) {
78
+ return { existingFiles: [], virtualFiles: [], program: undefined };
79
+ }
80
+ // Separate real files from virtual/cache files
81
+ // Virtual files include: stdin files (in cache dir), non-existent files
82
+ // TypeScript will surface opaque diagnostics for missing files; pre-filter so we only pay the program cost for real files.
83
+ const existingFiles = [];
84
+ const virtualFiles = [];
85
+ for (const file of unincludedFiles) {
86
+ const fileExists = fs.existsSync(file);
87
+ // Files that don't exist are always virtual
88
+ if (!fileExists) {
89
+ virtualFiles.push(file);
90
+ continue;
32
91
  }
33
- catch (error) {
34
- console.error(error);
92
+ // Check if file is in cache directory (like stdin files)
93
+ // These need tsconfig treatment even though they exist on disk
94
+ if (cacheLocation) {
95
+ const absolutePath = path.resolve(file);
96
+ const cacheRoot = path.resolve(cacheLocation);
97
+ const relativeToCache = path.relative(cacheRoot, absolutePath);
98
+ // File is inside cache if relative path doesn't escape (no '..')
99
+ const isInCache = !relativeToCache.startsWith('..') && !path.isAbsolute(relativeToCache);
100
+ if (isInCache) {
101
+ virtualFiles.push(file);
102
+ continue;
103
+ }
35
104
  }
105
+ existingFiles.push(file);
36
106
  }
37
- return { unincludedFiles, fallbackTsConfigPath };
107
+ return {
108
+ existingFiles,
109
+ virtualFiles,
110
+ program: createInMemoryProgram(existingFiles, cwd),
111
+ };
38
112
  }
39
113
  //# sourceMappingURL=handle-ts-files.js.map
@@ -2,7 +2,7 @@ import openEditor from 'open-editor';
2
2
  const sortResults = (a, b) => a.errorCount + b.errorCount > 0 ? (a.errorCount - b.errorCount) : (a.warningCount - b.warningCount);
3
3
  const resultToFile = (result) => {
4
4
  const [message] = result.messages
5
- .sort((a, b) => {
5
+ .toSorted((a, b) => {
6
6
  if (a.severity < b.severity) {
7
7
  return 1;
8
8
  }
@@ -25,7 +25,7 @@ const resultToFile = (result) => {
25
25
  };
26
26
  const getFiles = (report, predicate) => report.results
27
27
  .filter(result => predicate(result))
28
- .sort(sortResults)
28
+ .toSorted(sortResults)
29
29
  .map(result => resultToFile(result));
30
30
  const openReport = async (report) => {
31
31
  const count = report.errorCount > 0 ? 'errorCount' : 'warningCount';
@@ -17,14 +17,11 @@ export async function resolveXoConfig(options) {
17
17
  searchPlaces: [
18
18
  'package.json',
19
19
  `${moduleName}.config.js`,
20
- `${moduleName}.config.cjs`,
21
20
  `${moduleName}.config.mjs`,
22
21
  `${moduleName}.config.ts`,
23
- `${moduleName}.config.cts`,
24
22
  `${moduleName}.config.mts`,
25
23
  ],
26
24
  loaders: {
27
- '.cts': defaultLoaders['.ts'], // eslint-disable-line @typescript-eslint/naming-convention
28
25
  '.mts': defaultLoaders['.ts'], // eslint-disable-line @typescript-eslint/naming-convention
29
26
  },
30
27
  stopDir: stopDirectory,
@@ -33,7 +30,9 @@ export async function resolveXoConfig(options) {
33
30
  options.filePath &&= path.resolve(options.cwd, options.filePath);
34
31
  const searchPath = options.filePath ?? options.cwd;
35
32
  let { config: flatOptions = [], filepath: flatConfigPath = '', } = await (options.configPath
33
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
36
34
  ? flatConfigExplorer.load(path.resolve(options.cwd, options.configPath))
35
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
37
36
  : flatConfigExplorer.search(searchPath)) ?? {};
38
37
  flatOptions = arrify(flatOptions);
39
38
  return {
@@ -71,9 +71,11 @@ export type XoConfigItem = Simplify<XoConfigOptions & Omit<Linter.Config, 'files
71
71
  /**
72
72
  An array of glob patterns indicating the files that the configuration object should apply to. If not specified, the configuration object applies to all files.
73
73
 
74
+ Accepts a single glob string, an array of globs, or ESLint's native format where nested arrays create AND patterns.
75
+
74
76
  @see [Ignore Patterns](https://eslint.org/docs/latest/user-guide/configuring/configuration-files-new#excluding-files-with-ignores)
75
77
  */
76
- files?: string | string[] | undefined;
78
+ files?: string | Array<string | string[]> | undefined;
77
79
  /**
78
80
  An array of glob patterns indicating the files that the configuration object should not apply to. If not specified, the configuration object applies to all files matched by files.
79
81
 
@@ -1,6 +1,6 @@
1
1
  import { type Linter } from 'eslint';
2
- import { type SetRequired } from 'type-fest';
3
2
  import { type XoConfigItem } from './types.js';
3
+ export declare const typescriptParser: Linter.Parser | undefined;
4
4
  /**
5
5
  Convert a `xo` config item to an ESLint config item.
6
6
 
@@ -11,7 +11,7 @@ Files and rules will always be defined and all other ESLint config properties ar
11
11
  @param xoConfig
12
12
  @returns eslintConfig
13
13
  */
14
- export declare const xoToEslintConfigItem: (xoConfig: XoConfigItem) => SetRequired<Linter.Config, "rules" | "files">;
14
+ export declare const xoToEslintConfigItem: (xoConfig: XoConfigItem) => Linter.Config;
15
15
  /**
16
16
  Function used to match files which should be included in the `tsconfig.json` files.
17
17
 
@@ -21,7 +21,7 @@ Function used to match files which should be included in the `tsconfig.json` fil
21
21
  @param ignores - The globs to ignore when matching the files.
22
22
  @returns An array of file paths that match the globs and do not match the ignores.
23
23
  */
24
- export declare const matchFilesForTsConfig: (cwd: string, files: string[], globs: string[], ignores: string[]) => string[];
24
+ export declare const matchFilesForTsConfig: (cwd: string, files: string[] | undefined, globs: string[], ignores: string[]) => string[];
25
25
  /**
26
26
  Once a config is resolved, it is pre-processed to ensure that all properties are set correctly.
27
27
 
package/dist/lib/utils.js CHANGED
@@ -3,6 +3,14 @@ import micromatch from 'micromatch';
3
3
  import arrify from 'arrify';
4
4
  import configXoTypescript from 'eslint-config-xo-typescript';
5
5
  import { allFilesGlob, jsExtensions, jsFilesGlob, } from './constants.js';
6
+ const typescriptParserConfig = configXoTypescript.find(config => {
7
+ const languageOptions = config.languageOptions;
8
+ return languageOptions?.parser;
9
+ });
10
+ export const typescriptParser = typescriptParserConfig?.languageOptions?.parser;
11
+ if (!typescriptParser) {
12
+ throw new Error('XO: Failed to locate TypeScript parser in eslint-config-xo-typescript');
13
+ }
6
14
  /**
7
15
  Convert a `xo` config item to an ESLint config item.
8
16
 
@@ -17,8 +25,8 @@ export const xoToEslintConfigItem = (xoConfig) => {
17
25
  const { files, rules, space, prettier, ignores, semicolon, react, ..._xoConfig } = xoConfig;
18
26
  const eslintConfig = {
19
27
  ..._xoConfig,
20
- files: arrify(xoConfig.files ?? allFilesGlob),
21
- rules: xoConfig.rules ?? {},
28
+ ...(xoConfig.files ? { files: arrify(xoConfig.files) } : {}),
29
+ ...(xoConfig.rules ? { rules: xoConfig.rules } : {}),
22
30
  };
23
31
  eslintConfig.ignores &&= arrify(xoConfig.ignores);
24
32
  return eslintConfig;
@@ -32,7 +40,7 @@ Function used to match files which should be included in the `tsconfig.json` fil
32
40
  @param ignores - The globs to ignore when matching the files.
33
41
  @returns An array of file paths that match the globs and do not match the ignores.
34
42
  */
35
- export const matchFilesForTsConfig = (cwd, files, globs, ignores) => micromatch(files.map(file => path.normalize(path.relative(cwd, file))),
43
+ export const matchFilesForTsConfig = (cwd, files, globs, ignores) => micromatch(files?.map(file => path.normalize(path.relative(cwd, file))) ?? [],
36
44
  // https://github.com/micromatch/micromatch/issues/217
37
45
  globs.map(glob => path.normalize(glob)), {
38
46
  dot: true,
@@ -47,11 +55,14 @@ This includes ensuring that user-defined properties can override XO defaults, an
47
55
  @param xoConfig - The flat XO config to pre-process.
48
56
  @returns The pre-processed flat XO config.
49
57
  */
58
+ // eslint-disable-next-line complexity
50
59
  export const preProcessXoConfig = (xoConfig) => {
51
60
  const tsFilesGlob = [];
52
61
  const tsFilesIgnoresGlob = [];
53
62
  const processedConfig = [];
54
63
  for (const [idx, { ...config }] of xoConfig.entries()) {
64
+ const languageOptions = config.languageOptions;
65
+ const parserOptions = languageOptions?.parserOptions;
55
66
  // We can skip the first config item, as it is the base config item.
56
67
  if (idx === 0) {
57
68
  processedConfig.push(config);
@@ -60,8 +71,10 @@ export const preProcessXoConfig = (xoConfig) => {
60
71
  // Use TS parser/plugin for JS files if the config contains TypeScript rules which are applied to JS files.
61
72
  // typescript-eslint rules set to "off" are ignored and not applied to JS files.
62
73
  if (config.rules
63
- && !config.languageOptions?.parser
64
- && !config.languageOptions?.parserOptions?.['project']
74
+ // eslint-disable-next-line @typescript-eslint/dot-notation
75
+ && !languageOptions?.['parser']
76
+ && parserOptions?.project === undefined
77
+ && parserOptions?.programs === undefined
65
78
  && !config.plugins?.['@typescript-eslint']) {
66
79
  const hasTsRules = Object.entries(config.rules).some(rulePair => {
67
80
  // If its not a @typescript-eslint rule, we don't care
@@ -77,7 +90,7 @@ export const preProcessXoConfig = (xoConfig) => {
77
90
  if (hasTsRules) {
78
91
  let isAppliedToJsFiles = false;
79
92
  if (config.files) {
80
- const normalizedFiles = arrify(config.files).map(file => path.normalize(file));
93
+ const normalizedFiles = arrify(config.files).flat().map(file => path.normalize(file));
81
94
  // Strip the basename off any globs
82
95
  const globs = normalizedFiles.map(file => micromatch.scan(file, { dot: true }).glob).filter(Boolean);
83
96
  // Check if the files globs match a test file with a js extension
@@ -89,25 +102,27 @@ export const preProcessXoConfig = (xoConfig) => {
89
102
  isAppliedToJsFiles = true;
90
103
  }
91
104
  if (isAppliedToJsFiles) {
92
- config.languageOptions ??= {};
105
+ const updatedLanguageOptions = languageOptions
106
+ ? { ...languageOptions, parser: typescriptParser }
107
+ : { parser: typescriptParser };
108
+ config.languageOptions = updatedLanguageOptions;
93
109
  config.plugins ??= {};
94
110
  config.plugins = {
95
111
  ...config.plugins,
96
112
  ...configXoTypescript[1]?.plugins,
97
113
  };
98
- config.languageOptions.parser = configXoTypescript[1]?.languageOptions?.parser;
99
- tsFilesGlob.push(...arrify(config.files ?? allFilesGlob));
114
+ tsFilesGlob.push(...arrify(config.files ?? allFilesGlob).flat());
100
115
  tsFilesIgnoresGlob.push(...arrify(config.ignores));
101
116
  }
102
117
  }
103
118
  }
104
- // If a user sets the `parserOptions.project` or `projectService` or `tsconfigRootDir`, we need to ensure that the tsFilesGlob is set to exclude those files,
105
- // as this indicates the user has opted out of the default TypeScript handling for those files.
106
- if (config.languageOptions?.parserOptions?.['project'] !== undefined
107
- || config.languageOptions?.parserOptions?.['projectService'] !== undefined
108
- || config.languageOptions?.parserOptions?.['tsconfigRootDir'] !== undefined) {
119
+ // If the config sets `parserOptions.project`, `projectService`, `tsconfigRootDir`, or `programs`, treat those files as opt-out for XO's automatic program wiring.
120
+ if (parserOptions?.project !== undefined
121
+ || parserOptions?.projectService !== undefined
122
+ || parserOptions?.tsconfigRootDir !== undefined
123
+ || parserOptions?.programs !== undefined) {
109
124
  // The glob itself should NOT be negated
110
- tsFilesIgnoresGlob.push(...arrify(config.files ?? allFilesGlob));
125
+ tsFilesIgnoresGlob.push(...arrify(config.files ?? allFilesGlob).flat());
111
126
  }
112
127
  processedConfig.push(config);
113
128
  }
@@ -4,13 +4,57 @@ import arrify from 'arrify';
4
4
  import configReact from 'eslint-config-xo-react';
5
5
  import pluginPrettier from 'eslint-plugin-prettier';
6
6
  import eslintConfigPrettier from 'eslint-config-prettier';
7
+ import { fixupConfigRules, fixupPluginRules } from '@eslint/compat';
7
8
  import { config } from './config.js';
8
9
  import { xoToEslintConfigItem } from './utils.js';
9
10
  /**
11
+ Merge all plugins from every config into a single config entry at the start of the array, ensuring user-provided plugins take precedence. This avoids ESLint's flat config rejecting duplicate plugin names.
12
+ */
13
+ const hoistPlugins = (configs, userPluginOverrides) => {
14
+ const plugins = {};
15
+ const configsWithoutPlugins = [];
16
+ for (const configItem of configs) {
17
+ const { plugins: configPlugins } = configItem;
18
+ if (!configPlugins) {
19
+ configsWithoutPlugins.push(configItem);
20
+ continue;
21
+ }
22
+ // ESLint flat config rejects duplicate plugin names, so merge all plugins into one config.
23
+ Object.assign(plugins, configPlugins);
24
+ const { plugins: _ignored, ...configWithoutPlugins } = configItem;
25
+ if (Object.keys(configWithoutPlugins).length > 0) {
26
+ configsWithoutPlugins.push(configWithoutPlugins);
27
+ }
28
+ }
29
+ for (const [pluginName, plugin] of userPluginOverrides) {
30
+ plugins[pluginName] = plugin;
31
+ }
32
+ if (Object.keys(plugins).length === 0) {
33
+ return configsWithoutPlugins;
34
+ }
35
+ return [
36
+ {
37
+ name: 'xo/plugins',
38
+ plugins,
39
+ },
40
+ ...configsWithoutPlugins,
41
+ ];
42
+ };
43
+ /**
10
44
  Takes a XO flat config and returns an ESlint flat config.
11
45
  */
12
46
  export function xoToEslintConfig(flatXoConfig, { prettierOptions = {} } = {}) {
13
47
  const baseConfig = [...config];
48
+ const userPluginOverrides = new Map();
49
+ for (const xoConfigItem of flatXoConfig ?? []) {
50
+ const { plugins } = xoConfigItem;
51
+ if (!plugins) {
52
+ continue;
53
+ }
54
+ for (const [pluginName, plugin] of Object.entries(plugins)) {
55
+ userPluginOverrides.set(pluginName, plugin);
56
+ }
57
+ }
14
58
  /**
15
59
  Since configs are merged and the last config takes precedence this means we need to handle both true AND false cases for each option. For example, we need to turn `prettier`, `space`, `semi`, etc. on or off for a specific file.
16
60
  */
@@ -35,6 +79,7 @@ export function xoToEslintConfig(flatXoConfig, { prettierOptions = {} } = {}) {
35
79
  */
36
80
  const eslintConfigItem = xoToEslintConfigItem(xoConfigItem);
37
81
  if (xoConfigItem.semicolon === false) {
82
+ eslintConfigItem.rules ??= {};
38
83
  eslintConfigItem.rules['@stylistic/semi'] = ['error', 'never'];
39
84
  eslintConfigItem.rules['@stylistic/semi-spacing'] = [
40
85
  'error',
@@ -43,6 +88,7 @@ export function xoToEslintConfig(flatXoConfig, { prettierOptions = {} } = {}) {
43
88
  }
44
89
  if (xoConfigItem.space) {
45
90
  const spaces = typeof xoConfigItem.space === 'number' ? xoConfigItem.space : 2;
91
+ eslintConfigItem.rules ??= {};
46
92
  eslintConfigItem.rules['@stylistic/indent'] = [
47
93
  'error',
48
94
  spaces,
@@ -54,12 +100,14 @@ export function xoToEslintConfig(flatXoConfig, { prettierOptions = {} } = {}) {
54
100
  else if (xoConfigItem.space === false) {
55
101
  // If a user sets this to false for a small subset of files for some reason,
56
102
  // then we need to set them back to their original values.
103
+ eslintConfigItem.rules ??= {};
57
104
  eslintConfigItem.rules['@stylistic/indent'] = configXoTypescript[1]?.rules?.['@stylistic/indent'];
58
105
  eslintConfigItem.rules['@stylistic/indent-binary-ops'] = configXoTypescript[1]?.rules?.['@stylistic/indent-binary-ops'];
59
106
  }
60
107
  if (xoConfigItem.react) {
61
108
  // Ensure the files applied to the React config are the same as the config they are derived from
62
- baseConfig.push({ ...configReact[0], files: eslintConfigItem.files });
109
+ // TODO: Remove `fixupConfigRules` wrapping when eslint-config-xo-react supports ESLint 10 natively.
110
+ baseConfig.push({ ...fixupConfigRules(configReact)[0], files: eslintConfigItem.files, name: 'xo/react' });
63
111
  }
64
112
  // Prettier should generally be the last config in the array
65
113
  if (xoConfigItem.prettier) {
@@ -68,7 +116,7 @@ export function xoToEslintConfig(flatXoConfig, { prettierOptions = {} } = {}) {
68
116
  }
69
117
  else {
70
118
  // Validate that Prettier options match other `xoConfig` options.
71
- /* eslint-disable-next-line */
119
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
72
120
  if ((xoConfigItem.semicolon && prettierOptions.semi === false) || (!xoConfigItem.semicolon && prettierOptions.semi === true)) {
73
121
  throw new Error(`The Prettier config \`semi\` is ${prettierOptions.semi} while Xo \`semicolon\` is ${xoConfigItem.semicolon}, also check your .editorconfig for inconsistencies.`);
74
122
  }
@@ -79,9 +127,10 @@ export function xoToEslintConfig(flatXoConfig, { prettierOptions = {} } = {}) {
79
127
  throw new Error(`The Prettier config \`tabWidth\` is ${prettierOptions.tabWidth} while Xo \`space\` is ${xoConfigItem.space}, also check your .editorconfig for inconsistencies.`);
80
128
  }
81
129
  // Add Prettier plugin
130
+ // TODO: Remove `fixupPluginRules` wrapping when eslint-plugin-prettier supports ESLint 10 natively.
82
131
  eslintConfigItem.plugins = {
83
132
  ...eslintConfigItem.plugins,
84
- prettier: pluginPrettier,
133
+ prettier: fixupPluginRules(pluginPrettier),
85
134
  };
86
135
  const prettierConfig = {
87
136
  singleQuote: true,
@@ -96,6 +145,7 @@ export function xoToEslintConfig(flatXoConfig, { prettierOptions = {} } = {}) {
96
145
  // Configure Prettier rules
97
146
  const rulesWithPrettier = {
98
147
  ...eslintConfigItem.rules,
148
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
99
149
  ...pluginPrettier.configs?.['recommended']?.rules,
100
150
  // eslint-disable-next-line @typescript-eslint/naming-convention
101
151
  'prettier/prettier': ['error', prettierConfig],
@@ -106,11 +156,16 @@ export function xoToEslintConfig(flatXoConfig, { prettierOptions = {} } = {}) {
106
156
  }
107
157
  else if (xoConfigItem.prettier === false) {
108
158
  // Turn Prettier off for a subset of files
159
+ eslintConfigItem.rules ??= {};
109
160
  eslintConfigItem.rules['prettier/prettier'] = 'off';
110
161
  }
162
+ if (Object.keys(eslintConfigItem).length === 0) {
163
+ continue;
164
+ }
111
165
  baseConfig.push(eslintConfigItem);
112
166
  }
113
- return baseConfig;
167
+ // User plugins should always win, even if XO injects plugins later in the config list.
168
+ return hoistPlugins(baseConfig, userPluginOverrides);
114
169
  }
115
170
  export default xoToEslintConfig;
116
171
  //# sourceMappingURL=xo-to-eslint.js.map
package/dist/lib/xo.d.ts CHANGED
@@ -2,6 +2,7 @@ import { ESLint, type Linter } from 'eslint';
2
2
  import prettier from 'prettier';
3
3
  import { type XoLintResult, type LinterOptions, type LintTextOptions, type XoConfigOptions, type XoConfigItem } from './types.js';
4
4
  import { xoToEslintConfig } from './xo-to-eslint.js';
5
+ export declare const ignoredFileWarningMessage = "File ignored because of a matching ignore pattern.";
5
6
  export declare class Xo {
6
7
  /**
7
8
  Static helper to convert an XO config to an ESLint config to be used in `eslint.config.js`.
@@ -65,6 +66,21 @@ export declare class Xo {
65
66
  We use this to also add negative glob patterns in case a user overrides the parserOptions in their XO config.
66
67
  */
67
68
  tsFilesIgnoresGlob: string[];
69
+ /**
70
+ Track whether ignores have been added to prevent duplicate ignore configs.
71
+ */
72
+ private ignoresHandled;
73
+ /**
74
+ Store per-file configs separately from base config to prevent unbounded array growth.
75
+ Key: file path, Value: config for that file.
76
+ This prevents memory bloat in long-running processes (e.g., language servers).
77
+ */
78
+ private readonly fileConfigs;
79
+ /**
80
+ Track virtual/stdin files that share a single tsconfig.stdin.json.
81
+ These are handled differently from regular files.
82
+ */
83
+ private readonly virtualFiles;
68
84
  constructor(_linterOptions: LinterOptions, _baseXoConfig?: XoConfigOptions);
69
85
  /**
70
86
  Sets the XO config on the XO instance.
@@ -91,7 +107,7 @@ export declare class Xo {
91
107
  */
92
108
  ensureCacheDirectory(): Promise<void>;
93
109
  /**
94
- Checks every TS file to ensure its included in the tsconfig and any that are not included are added to a generated tsconfig for type aware linting.
110
+ 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.
95
111
 
96
112
  @param files - The TypeScript files being linted.
97
113
  */
@@ -113,6 +129,14 @@ export declare class Xo {
113
129
  lintText(code: string, lintTextOptions: LintTextOptions): Promise<XoLintResult>;
114
130
  calculateConfigForFile(filePath: string): Promise<Linter.Config>;
115
131
  getFormatter(name: string): Promise<ESLint.LoadedFormatter>;
132
+ /**
133
+ Add virtual files to the config with a tsconfig approach.
134
+ */
135
+ private addVirtualFilesToConfig;
136
+ /**
137
+ Add existing files to the config with an in-memory TypeScript Program.
138
+ */
139
+ private addExistingFilesToConfig;
116
140
  private processReport;
117
141
  private getReportStatistics;
118
142
  }
package/dist/lib/xo.js CHANGED
@@ -1,18 +1,62 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
+ import syncFs from 'node:fs';
3
4
  import fs from 'node:fs/promises';
4
5
  import process from 'node:process';
5
6
  import { ESLint } from 'eslint';
6
7
  import findCacheDirectory from 'find-cache-directory';
7
- import { globby } from 'globby';
8
+ import { globby, isDynamicPattern } from 'globby';
8
9
  import arrify from 'arrify';
9
10
  import defineLazyProperty from 'define-lazy-prop';
10
11
  import prettier from 'prettier';
11
- import { defaultIgnores, cacheDirName, allExtensions, tsFilesGlob, } from './constants.js';
12
+ import { defaultIgnores, cacheDirName, allExtensions, tsFilesGlob, tsconfigDefaults, } from './constants.js';
12
13
  import { xoToEslintConfig } from './xo-to-eslint.js';
13
14
  import resolveXoConfig from './resolve-config.js';
14
15
  import { handleTsconfig } from './handle-ts-files.js';
15
- import { matchFilesForTsConfig, preProcessXoConfig } from './utils.js';
16
+ import { matchFilesForTsConfig, preProcessXoConfig, typescriptParser, } from './utils.js';
17
+ export const ignoredFileWarningMessage = 'File ignored because of a matching ignore pattern.';
18
+ const createIgnoredLintResult = (filePath) => ({
19
+ filePath,
20
+ messages: [
21
+ {
22
+ ruleId: null,
23
+ severity: 1,
24
+ message: ignoredFileWarningMessage,
25
+ line: 0,
26
+ column: 0,
27
+ },
28
+ ],
29
+ suppressedMessages: [],
30
+ errorCount: 0,
31
+ fatalErrorCount: 0,
32
+ warningCount: 1,
33
+ fixableErrorCount: 0,
34
+ fixableWarningCount: 0,
35
+ usedDeprecatedRules: [],
36
+ });
37
+ const resolveExplicitFilePath = (cwd, glob) => {
38
+ if (isDynamicPattern(glob)) {
39
+ // Negated and wildcard globs are treated as regular glob filtering, not as explicit file paths that should trigger an ignored-file warning.
40
+ return undefined;
41
+ }
42
+ const absolutePath = path.resolve(cwd, glob);
43
+ try {
44
+ if (syncFs.statSync(absolutePath).isFile()) {
45
+ return absolutePath;
46
+ }
47
+ }
48
+ catch {
49
+ // File does not exist or is inaccessible.
50
+ }
51
+ return undefined;
52
+ };
53
+ const getIgnoredExplicitFileResults = async (cwd, globs, eslint) => {
54
+ const explicitFilePaths = [...new Set(globs
55
+ .map(glob => resolveExplicitFilePath(cwd, glob))
56
+ .filter(filePath => filePath !== undefined))];
57
+ const results = await Promise.all(explicitFilePaths.map(async (filePath) => await eslint.isPathIgnored(filePath) ? createIgnoredLintResult(filePath) : undefined));
58
+ return results.filter(result => result !== undefined);
59
+ };
16
60
  export class Xo {
17
61
  /**
18
62
  Static helper to convert an XO config to an ESLint config to be used in `eslint.config.js`.
@@ -113,6 +157,21 @@ export class Xo {
113
157
  We use this to also add negative glob patterns in case a user overrides the parserOptions in their XO config.
114
158
  */
115
159
  tsFilesIgnoresGlob = [];
160
+ /**
161
+ Track whether ignores have been added to prevent duplicate ignore configs.
162
+ */
163
+ ignoresHandled = false;
164
+ /**
165
+ Store per-file configs separately from base config to prevent unbounded array growth.
166
+ Key: file path, Value: config for that file.
167
+ This prevents memory bloat in long-running processes (e.g., language servers).
168
+ */
169
+ fileConfigs = new Map();
170
+ /**
171
+ Track virtual/stdin files that share a single tsconfig.stdin.json.
172
+ These are handled differently from regular files.
173
+ */
174
+ virtualFiles = new Set();
116
175
  constructor(_linterOptions, _baseXoConfig = {}) {
117
176
  this.linterOptions = _linterOptions;
118
177
  this.baseXoConfig = _baseXoConfig;
@@ -120,6 +179,12 @@ export class Xo {
120
179
  if (!path.isAbsolute(this.linterOptions.cwd)) {
121
180
  this.linterOptions.cwd = path.resolve(process.cwd(), this.linterOptions.cwd);
122
181
  }
182
+ try {
183
+ this.linterOptions.cwd = syncFs.realpathSync.native(this.linterOptions.cwd);
184
+ }
185
+ catch {
186
+ // Ignore invalid paths here; the caller will handle errors later.
187
+ }
123
188
  const backupCacheLocation = path.join(os.tmpdir(), cacheDirName);
124
189
  this.cacheLocation = findCacheDirectory({ name: cacheDirName, cwd: this.linterOptions.cwd }) ?? backupCacheLocation;
125
190
  }
@@ -155,7 +220,12 @@ export class Xo {
155
220
  if (!this.xoConfig) {
156
221
  throw new Error('"Xo.setEslintConfig" failed');
157
222
  }
158
- this.eslintConfig ??= xoToEslintConfig([...this.xoConfig], { prettierOptions: this.prettierConfig });
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 });
159
229
  }
160
230
  /**
161
231
  Sets the ignores on the XO instance.
@@ -163,7 +233,7 @@ export class Xo {
163
233
  @private
164
234
  */
165
235
  setIgnores() {
166
- if (!this.baseXoConfig.ignores) {
236
+ if (this.ignoresHandled || !this.baseXoConfig.ignores) {
167
237
  return;
168
238
  }
169
239
  let ignores = [];
@@ -180,6 +250,7 @@ export class Xo {
180
250
  return;
181
251
  }
182
252
  this.xoConfig.push({ ignores });
253
+ this.ignoresHandled = true;
183
254
  }
184
255
  /**
185
256
  Ensures the cache directory exists. This needs to run once before both tsconfig handling and running ESLint occur.
@@ -201,7 +272,7 @@ export class Xo {
201
272
  }
202
273
  }
203
274
  /**
204
- Checks every TS file to ensure its included in the tsconfig and any that are not included are added to a generated tsconfig for type aware linting.
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.
205
276
 
206
277
  @param files - The TypeScript files being linted.
207
278
  */
@@ -209,25 +280,25 @@ export class Xo {
209
280
  if (!this.linterOptions.ts || !files || files.length === 0) {
210
281
  return;
211
282
  }
212
- const tsFiles = matchFilesForTsConfig(this.linterOptions.cwd, files, this.tsFilesGlob, this.tsFilesIgnoresGlob);
213
- if (tsFiles.length === 0) {
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
+ }
214
290
  return;
215
291
  }
216
- const { fallbackTsConfigPath, unincludedFiles } = await handleTsconfig({
292
+ const { program, existingFiles, virtualFiles } = handleTsconfig({
293
+ files: allTsFiles,
217
294
  cwd: this.linterOptions.cwd,
218
- files: tsFiles,
295
+ cacheLocation: this.cacheLocation,
219
296
  });
220
- if (!this.xoConfig || unincludedFiles.length === 0) {
221
- return;
297
+ this.fileConfigs.clear();
298
+ if (existingFiles.length > 0) {
299
+ this.addExistingFilesToConfig(existingFiles, program);
222
300
  }
223
- const config = {};
224
- config.files = unincludedFiles.map(file => path.relative(this.linterOptions.cwd, file));
225
- config.languageOptions ??= {};
226
- config.languageOptions.parserOptions ??= {};
227
- config.languageOptions.parserOptions['projectService'] = false;
228
- config.languageOptions.parserOptions['project'] = fallbackTsConfigPath;
229
- config.languageOptions.parserOptions['tsconfigRootDir'] = this.linterOptions.cwd;
230
- this.xoConfig.push(config);
301
+ await this.addVirtualFilesToConfig(virtualFiles);
231
302
  }
232
303
  /**
233
304
  Initializes the ESLint instance on the XO instance.
@@ -249,9 +320,12 @@ export class Xo {
249
320
  warnIgnored: false,
250
321
  cache: true,
251
322
  cacheLocation: this.cacheLocation,
323
+ cacheStrategy: 'content',
252
324
  fix: this.linterOptions.fix,
253
325
  };
254
- this.eslint ??= new ESLint(eslintOptions);
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);
255
329
  }
256
330
  /**
257
331
  Lints the files on the XO instance.
@@ -264,24 +338,28 @@ export class Xo {
264
338
  globs = `**/*.{${allExtensions.join(',')}}`;
265
339
  }
266
340
  globs = arrify(globs);
267
- let files = await globby(globs, {
341
+ const files = await globby(globs, {
268
342
  // Merge in command line ignores
269
343
  ignore: [...defaultIgnores, ...arrify(this.baseXoConfig.ignores)],
270
344
  onlyFiles: true,
271
345
  gitignore: true,
272
346
  absolute: true,
347
+ dot: true,
273
348
  cwd: this.linterOptions.cwd,
274
349
  });
275
350
  await this.initEslint(files);
276
351
  if (!this.eslint) {
277
352
  throw new Error('Failed to initialize ESLint');
278
353
  }
354
+ const { eslint } = this;
355
+ const ignoredResults = await getIgnoredExplicitFileResults(this.linterOptions.cwd, globs, eslint);
279
356
  if (files.length === 0) {
280
- files = '!**/*';
357
+ return this.processReport(ignoredResults);
281
358
  }
282
- const results = await this.eslint.lintFiles(files);
283
- const rulesMeta = this.eslint.getRulesMetaForResults(results);
284
- return this.processReport(results, { rulesMeta });
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 });
285
363
  }
286
364
  /**
287
365
  Lints the text on the XO instance.
@@ -304,6 +382,7 @@ export class Xo {
304
382
  if (!this.eslint) {
305
383
  throw new Error('Failed to initialize ESLint');
306
384
  }
385
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
307
386
  return this.eslint.calculateConfigForFile(filePath);
308
387
  }
309
388
  async getFormatter(name) {
@@ -313,6 +392,102 @@ export class Xo {
313
392
  }
314
393
  return this.eslint.loadFormatter(name);
315
394
  }
395
+ /**
396
+ Add virtual files to the config with a tsconfig approach.
397
+ */
398
+ async addVirtualFilesToConfig(files) {
399
+ if (!this.xoConfig) {
400
+ return;
401
+ }
402
+ try {
403
+ const nextVirtualFiles = new Set(files);
404
+ const tsconfigPath = path.join(this.cacheLocation, 'tsconfig.stdin.json');
405
+ const configIndex = this.xoConfig.findIndex(configItem => {
406
+ const { languageOptions } = configItem;
407
+ const parserOptionsCandidate = languageOptions?.parserOptions;
408
+ const parserOptions = parserOptionsCandidate;
409
+ return parserOptions?.project === tsconfigPath;
410
+ });
411
+ if (nextVirtualFiles.size > 0) {
412
+ const filesArray = [...nextVirtualFiles];
413
+ const relativeFiles = filesArray.map(file => path.relative(this.linterOptions.cwd, file));
414
+ const tsconfigContent = {
415
+ compilerOptions: {
416
+ ...tsconfigDefaults.compilerOptions,
417
+ module: 'ESNext',
418
+ moduleResolution: 'NodeNext',
419
+ esModuleInterop: true,
420
+ skipLibCheck: true,
421
+ },
422
+ files: filesArray,
423
+ };
424
+ await fs.writeFile(tsconfigPath, JSON.stringify(tsconfigContent, null, 2));
425
+ if (configIndex === -1) {
426
+ const parserOptions = {
427
+ projectService: false,
428
+ project: tsconfigPath,
429
+ tsconfigRootDir: this.linterOptions.cwd,
430
+ };
431
+ this.xoConfig.push({
432
+ files: relativeFiles,
433
+ languageOptions: {
434
+ parser: typescriptParser,
435
+ parserOptions,
436
+ },
437
+ });
438
+ }
439
+ else {
440
+ const existingConfig = this.xoConfig[configIndex];
441
+ this.xoConfig[configIndex] = {
442
+ ...existingConfig,
443
+ files: relativeFiles,
444
+ };
445
+ }
446
+ this.virtualFiles.clear();
447
+ for (const file of nextVirtualFiles) {
448
+ this.virtualFiles.add(file);
449
+ }
450
+ return;
451
+ }
452
+ if (configIndex >= 0) {
453
+ this.xoConfig.splice(configIndex, 1);
454
+ }
455
+ this.virtualFiles.clear();
456
+ await fs.rm(tsconfigPath, { force: true });
457
+ }
458
+ catch (error) {
459
+ console.warn('XO: Failed to create tsconfig for virtual files. Type-aware linting will be disabled for these files.', error instanceof Error ? error.message : String(error));
460
+ }
461
+ }
462
+ /**
463
+ Add existing files to the config with an in-memory TypeScript Program.
464
+ */
465
+ addExistingFilesToConfig(files, program) {
466
+ if (!this.xoConfig || files.length === 0) {
467
+ return;
468
+ }
469
+ const parserOptions = {
470
+ project: false,
471
+ projectService: false,
472
+ };
473
+ if (program) {
474
+ parserOptions.programs = [program];
475
+ }
476
+ const config = {
477
+ files: files.map(file => path.relative(this.linterOptions.cwd, file)),
478
+ languageOptions: {
479
+ parser: typescriptParser,
480
+ parserOptions,
481
+ },
482
+ };
483
+ // IMPORTANT: All files intentionally share the same config object reference for memory efficiency.
484
+ // This prevents unbounded memory growth in long-running processes (e.g., language servers).
485
+ // The config is immutable after creation, so sharing is safe.
486
+ // Deduplication happens in setEslintConfig() via Set to avoid duplicate configs in the final array.
487
+ for (const file of files) {
488
+ this.fileConfigs.set(file, config);
489
+ }
490
+ }
316
491
  processReport(report, { rulesMeta = {} } = {}) {
317
492
  if (this.linterOptions.quiet) {
318
493
  report = ESLint.getErrorResults(report);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xo",
3
- "version": "1.2.3",
3
+ "version": "2.0.0",
4
4
  "description": "JavaScript/TypeScript linter (ESLint wrapper) with great defaults",
5
5
  "license": "MIT",
6
6
  "repository": "xojs/xo",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "sideEffects": false,
20
20
  "engines": {
21
- "node": ">=20.17"
21
+ "node": ">=20.19"
22
22
  },
23
23
  "scripts": {
24
24
  "build": "npm run clean && tsc",
@@ -65,53 +65,51 @@
65
65
  "typescript"
66
66
  ],
67
67
  "dependencies": {
68
- "@eslint-community/eslint-plugin-eslint-comments": "^4.5.0",
69
- "@sindresorhus/tsconfig": "^7.0.0",
70
- "@stylistic/eslint-plugin": "^4.2.0",
71
- "@typescript-eslint/parser": "^8.37.0",
68
+ "@eslint-community/eslint-plugin-eslint-comments": "^4.7.1",
69
+ "@eslint/compat": "^2.0.2",
70
+ "@sindresorhus/tsconfig": "^8.1.0",
72
71
  "arrify": "^3.0.0",
73
72
  "cosmiconfig": "^9.0.0",
74
73
  "define-lazy-prop": "^3.0.0",
75
- "eslint": "^9.31.0",
76
- "eslint-config-prettier": "^10.1.5",
77
- "eslint-config-xo-react": "^0.28.0",
78
- "eslint-config-xo-typescript": "^7.0.0",
79
- "eslint-formatter-pretty": "^6.0.1",
80
- "eslint-plugin-ava": "^15.0.1",
74
+ "eslint": "^10.0.2",
75
+ "eslint-config-prettier": "^10.1.8",
76
+ "eslint-config-xo-react": "^0.29.0",
77
+ "eslint-config-xo-typescript": "^9.0.0",
78
+ "eslint-formatter-pretty": "^7.0.0",
79
+ "eslint-plugin-ava": "^16.0.0",
81
80
  "eslint-plugin-import-x": "^4.16.1",
82
- "eslint-plugin-n": "^17.21.0",
83
- "eslint-plugin-no-use-extend-native": "^0.7.2",
84
- "eslint-plugin-prettier": "^5.5.1",
85
- "eslint-plugin-promise": "^7.2.1",
86
- "eslint-plugin-unicorn": "^59.0.1",
81
+ "eslint-plugin-n": "^17.24.0",
82
+ "eslint-plugin-prettier": "^5.5.5",
83
+ "eslint-plugin-unicorn": "^63.0.0",
87
84
  "find-cache-directory": "^6.0.0",
88
- "get-stdin": "^9.0.0",
89
- "get-tsconfig": "^4.10.1",
90
- "globals": "^16.3.0",
91
- "globby": "^14.1.0",
92
- "meow": "^13.2.0",
85
+ "get-stdin": "^10.0.0",
86
+ "get-tsconfig": "^4.13.6",
87
+ "globals": "^17.3.0",
88
+ "globby": "^16.1.1",
89
+ "meow": "^14.1.0",
93
90
  "micromatch": "^4.0.8",
94
- "open-editor": "^5.1.0",
91
+ "open-editor": "^6.0.0",
95
92
  "path-exists": "^5.0.0",
96
- "prettier": "^3.6.2",
97
- "type-fest": "^4.41.0",
98
- "typescript-eslint": "^8.37.0"
93
+ "prettier": "^3.8.1",
94
+ "type-fest": "^5.4.3",
95
+ "typescript": "^5.9.3",
96
+ "typescript-eslint": "^8.56.1"
99
97
  },
100
98
  "devDependencies": {
101
- "@commitlint/cli": "^19.8.1",
102
- "@commitlint/config-conventional": "^19.8.1",
103
- "@types/eslint": "9.6.1",
104
- "@types/micromatch": "^4.0.9",
99
+ "@commitlint/cli": "^20.4.1",
100
+ "@commitlint/config-conventional": "^20.4.1",
101
+ "@types/micromatch": "^4.0.10",
102
+ "@types/node": "^25.2.1",
105
103
  "@types/prettier": "^3.0.0",
106
104
  "ava": "^6.4.1",
107
- "dedent": "^1.6.0",
108
- "execa": "^9.5.3",
105
+ "dedent": "^1.7.1",
106
+ "execa": "^9.6.1",
109
107
  "husky": "^9.1.7",
110
- "lint-staged": "^16.0.0",
111
- "np": "^10.2.0",
112
- "npm-package-json-lint": "^9.0.0",
108
+ "lint-staged": "^16.2.7",
109
+ "np": "^11.0.2",
110
+ "npm-package-json-lint": "^9.1.0",
113
111
  "npm-package-json-lint-config-default": "^8.0.1",
114
- "prettier-plugin-packagejson": "^2.5.18",
112
+ "prettier-plugin-packagejson": "^3.0.0",
115
113
  "temp-dir": "^3.0.0",
116
114
  "xo": "file:."
117
115
  },
@@ -143,5 +141,10 @@
143
141
  "text",
144
142
  "lcov"
145
143
  ]
144
+ },
145
+ "xo": {
146
+ "rules": {
147
+ "ava/no-ignored-test-files": "off"
148
+ }
146
149
  }
147
150
  }
package/readme.md CHANGED
@@ -27,7 +27,7 @@ It uses [ESLint](https://eslint.org) underneath, so issues regarding built-in ru
27
27
  - No need to specify file paths to lint as it lints all JS/TS files except for [commonly ignored paths](#ignores).
28
28
  - [Flat config customization.](#config)
29
29
  - [TypeScript supported by default.](#typescript)
30
- - Includes many useful ESLint plugins, like [`unicorn`](https://github.com/sindresorhus/eslint-plugin-unicorn), [`import`](https://github.com/benmosher/eslint-plugin-import), [`ava`](https://github.com/avajs/eslint-plugin-ava), [`n`](https://github.com/eslint-community/eslint-plugin-n) and more.
30
+ - Includes many useful ESLint plugins, like [`unicorn`](https://github.com/sindresorhus/eslint-plugin-unicorn), [`import-x`](https://github.com/un-ts/eslint-plugin-import-x), [`ava`](https://github.com/avajs/eslint-plugin-ava), [`n`](https://github.com/eslint-community/eslint-plugin-n) and more.
31
31
  - Caches results between runs for much better performance.
32
32
  - Super simple to add XO to a project with [`$ npm init xo`](https://github.com/xojs/create-xo).
33
33
  - Fix many issues automagically with `$ xo --fix`.
@@ -46,7 +46,7 @@ npm install xo --save-dev
46
46
 
47
47
  *You must install XO locally. You can run it directly with `$ npx xo`.*
48
48
 
49
- *You'll need [eslint-config-xo-vue](https://github.com/ChocPanda/eslint-config-xo-vue#use-with-xo) for specific linting in a Vue app.*
49
+ *For framework-specific linting, see [Astro](#astro), [Svelte](#svelte), and [Vue](#vue).*
50
50
 
51
51
  ## Usage
52
52
 
@@ -136,16 +136,16 @@ export default [...] satisfies import('xo').FlatXoConfig
136
136
 
137
137
  ### files
138
138
 
139
- Type: `string | string[] | undefined`\
139
+ Type: `string | (string | string[])[]`\
140
140
  Default: `**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx,vue,svelte,astro}`
141
141
 
142
- A glob or array of glob strings which the config object will apply. By default `XO` will apply the configuration to [all files](lib/constants.ts).
142
+ A glob string, array of globs, or ESLint's native format (where nested arrays create AND patterns) indicating which files the config object applies to. By default `XO` will apply the configuration to [all files](lib/constants.ts). This is compatible with ESLint plugin configs, so you can spread them directly into your XO config.
143
143
 
144
144
  > Tip: If you are adding additional `@typescript-eslint` rules to your config, these rules will apply to JS files as well unless you separate them appropriately with the `files` option. `@typescript-eslint` rules set to `'off'` or `0`, however, will have no effect on JS linting.
145
145
 
146
146
  ### ignores
147
147
 
148
- Type: `string[]`
148
+ Type: `string | string[]`
149
149
 
150
150
  Some [paths](lib/constants.ts) are ignored by default, including paths in `.gitignore`. Additional ignores can be added here.
151
151
 
@@ -204,6 +204,66 @@ Default: `false`
204
204
 
205
205
  Adds `eslint-plugin-react`, `eslint-plugin-react-hooks`, and `eslint-config-xo-react` to get all the React best practices applied automatically.
206
206
 
207
+ ### Astro
208
+
209
+ To lint [Astro](https://astro.build) files, install [`eslint-plugin-astro`](https://github.com/ota-meshi/eslint-plugin-astro):
210
+
211
+ ```sh
212
+ npm install --save-dev eslint-plugin-astro
213
+ ```
214
+
215
+ Then spread its recommended config in your `xo.config.js`:
216
+
217
+ ```js
218
+ import astroPlugin from 'eslint-plugin-astro';
219
+
220
+ const xoConfig = [
221
+ ...astroPlugin.configs.recommended,
222
+ ];
223
+
224
+ export default xoConfig;
225
+ ```
226
+
227
+ ### Svelte
228
+
229
+ To lint [Svelte](https://svelte.dev) files, install [`eslint-plugin-svelte`](https://github.com/sveltejs/eslint-plugin-svelte):
230
+
231
+ ```sh
232
+ npm install --save-dev eslint-plugin-svelte
233
+ ```
234
+
235
+ Then spread its recommended config in your `xo.config.js`:
236
+
237
+ ```js
238
+ import sveltePlugin from 'eslint-plugin-svelte';
239
+
240
+ const xoConfig = [
241
+ ...sveltePlugin.configs.recommended,
242
+ ];
243
+
244
+ export default xoConfig;
245
+ ```
246
+
247
+ ### Vue
248
+
249
+ To lint [Vue](https://vuejs.org) files, install [`eslint-plugin-vue`](https://github.com/vuejs/eslint-plugin-vue):
250
+
251
+ ```sh
252
+ npm install --save-dev eslint-plugin-vue
253
+ ```
254
+
255
+ Then spread its recommended config in your `xo.config.js`:
256
+
257
+ ```js
258
+ import vuePlugin from 'eslint-plugin-vue';
259
+
260
+ const xoConfig = [
261
+ ...vuePlugin.configs['flat/recommended'],
262
+ ];
263
+
264
+ export default xoConfig;
265
+ ```
266
+
207
267
  ## TypeScript
208
268
 
209
269
  XO will automatically lint TypeScript files (`.ts`, `.mts`, `.cts`, and `.tsx`) with the rules defined in [eslint-config-xo-typescript#use-with-xo](https://github.com/xojs/eslint-config-xo-typescript#use-with-xo).
@@ -306,6 +366,12 @@ Show the world you're using XO → [![XO code style](https://shields.io/badge/co
306
366
  [![XO code style](https://shields.io/badge/code_style-5ed9c7?logo=xo&labelColor=gray&logoSize=auto)](https://github.com/xojs/xo)
307
367
  ```
308
368
 
369
+ Large badge: [![XO code style](https://shields.io/badge/code_style-5ed9c7?style=for-the-badge&logo=xo&labelColor=gray&logoSize=auto)](https://github.com/xojs/xo)
370
+
371
+ ```md
372
+ [![XO code style](https://shields.io/badge/code_style-5ed9c7?style=for-the-badge&logo=xo&labelColor=gray&logoSize=auto)](https://github.com/xojs/xo)
373
+ ```
374
+
309
375
  Or [customize the badge](https://github.com/xojs/xo/issues/689#issuecomment-1253127616).
310
376
 
311
377
  You can also find some nice dynamic XO badges on [badgen.net](https://badgen.net/#xo).