diff --git a/.cspell.json b/.cspell.json
index 279727a77428..dc41dd9403fb 100644
--- a/.cspell.json
+++ b/.cspell.json
@@ -50,6 +50,7 @@
"Airbnb",
"Airbnb's",
"ambiently",
+ "allowdefaultprojectforfiles",
"Armano",
"astexplorer",
"Astro",
@@ -150,6 +151,7 @@
"unoptimized",
"unprefixed",
"upsert",
+ "useprojectservice",
"Waiblinger",
"warnonunsupportedtypescriptversion",
"Zacher"
diff --git a/docs/packages/TypeScript_ESTree.mdx b/docs/packages/TypeScript_ESTree.mdx
index 6454920b23c3..eb6a49c8a1bd 100644
--- a/docs/packages/TypeScript_ESTree.mdx
+++ b/docs/packages/TypeScript_ESTree.mdx
@@ -288,6 +288,15 @@ interface ProjectServiceOptions {
* Path to a TSConfig to use instead of TypeScript's default project configuration.
*/
defaultProject?: string;
+
+ /**
+ * The maximum number of files {@link allowDefaultProjectForFiles} may match.
+ * Each file match slows down linting, so if you do need to use this, please
+ * file an informative issue on typescript-eslint explaining why - so we can
+ * help you avoid using it!
+ * @default 8
+ */
+ maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING?: number;
}
interface ParserServices {
diff --git a/docs/troubleshooting/FAQ.mdx b/docs/troubleshooting/FAQ.mdx
index dd96a721bcc0..1ccc0d5a0210 100644
--- a/docs/troubleshooting/FAQ.mdx
+++ b/docs/troubleshooting/FAQ.mdx
@@ -32,6 +32,40 @@ If you don't find an existing extension rule, or the extension rule doesn't work
> We release a new version our tooling every week.
> _Please_ ensure that you [check our the latest list of "extension" rules](/rules/#extension-rules) **_before_** filing an issue.
+
+
+## I get errors telling me "Having many files run with the default project is known to cause performance issues and slow down linting."
+
+These errors are caused by using the [`EXPERIMENTAL_useProjectService`](../packages/Parser.mdx#experimental_useprojectservice) `allowDefaultProjectForFiles` with an excessively wide glob.
+`allowDefaultProjectForFiles` causes a new TypeScript "program" to be built for each "out of project" file it includes, which incurs a performance overhead for each file.
+
+To resolve this error, narrow the glob(s) used for `allowDefaultProjectForFiles` to include fewer files.
+For example:
+
+```diff title="eslint.config.js"
+parserOptions: {
+ EXPERIMENTAL_useProjectService: {
+ allowDefaultProjectForFiles: [
+- "**/*.js",
++ "./*.js"
+ ]
+ }
+}
+```
+
+You may also need to include more files in your `tsconfig.json`.
+For example:
+
+```diff title="tsconfig.json"
+"include": [
+ "src",
++ "*.js"
+]
+```
+
+If you cannot do this, please [file an issue on typescript-eslint's typescript-estree package](https://github.com/typescript-eslint/typescript-eslint/issues/new?assignees=&labels=enhancement%2Ctriage&projects=&template=07-enhancement-other.yaml&title=Enhancement%3A+%3Ca+short+description+of+my+proposal%3E) telling us your use case and why you need more out-of-project files linted.
+Be sure to include a minimal reproduction we can work with to understand your use case!
+
## I get errors telling me "ESLint was configured to run ... However, that TSConfig does not / none of those TSConfigs include this file"
These errors are caused by an ESLint config requesting type information be generated for a file that isn't included in the TypeScript configuration.
@@ -499,6 +533,6 @@ If you think you're having issues with performance, see our [Performance Trouble
## Are TypeScript project references supported?
-No, TypeScript project references are not yet supported.
+Yes, but only with [`EXPERIMENTAL_useProjectService`](../packages/Parser.mdx#experimental_useprojectservice).
See [issue #2094 discussing project references](https://github.com/typescript-eslint/typescript-eslint/issues/2094) for more details.
diff --git a/packages/typescript-estree/src/clear-caches.ts b/packages/typescript-estree/src/clear-caches.ts
index 015fd18e29c8..5e9867d3beab 100644
--- a/packages/typescript-estree/src/clear-caches.ts
+++ b/packages/typescript-estree/src/clear-caches.ts
@@ -1,5 +1,8 @@
import { clearWatchCaches } from './create-program/getWatchProgramsForProjects';
-import { clearProgramCache as clearProgramCacheOriginal } from './parser';
+import {
+ clearDefaultProjectMatchedFiles,
+ clearProgramCache as clearProgramCacheOriginal,
+} from './parser';
import {
clearTSConfigMatchCache,
clearTSServerProjectService,
@@ -14,6 +17,7 @@ import { clearGlobCache } from './parseSettings/resolveProjectList';
* - In custom lint tooling that iteratively lints one project at a time to prevent OOMs.
*/
export function clearCaches(): void {
+ clearDefaultProjectMatchedFiles();
clearProgramCacheOriginal();
clearWatchCaches();
clearTSConfigMatchCache();
diff --git a/packages/typescript-estree/src/create-program/createProjectService.ts b/packages/typescript-estree/src/create-program/createProjectService.ts
index c355033191c4..323d113f81eb 100644
--- a/packages/typescript-estree/src/create-program/createProjectService.ts
+++ b/packages/typescript-estree/src/create-program/createProjectService.ts
@@ -4,6 +4,9 @@ import os from 'node:os';
import type * as ts from 'typescript/lib/tsserverlibrary';
import type { ProjectServiceOptions } from '../parser-options';
+import { validateDefaultProjectForFilesGlob } from './validateDefaultProjectForFilesGlob';
+
+const DEFAULT_PROJECT_MATCHED_FILES_THRESHOLD = 8;
const doNothing = (): void => {};
@@ -15,13 +18,17 @@ export type TypeScriptProjectService = ts.server.ProjectService;
export interface ProjectServiceSettings {
allowDefaultProjectForFiles: string[] | undefined;
+ maximumDefaultProjectFileMatchCount: number;
service: TypeScriptProjectService;
}
export function createProjectService(
- options: boolean | ProjectServiceOptions | undefined,
+ optionsRaw: boolean | ProjectServiceOptions | undefined,
jsDocParsingMode: ts.JSDocParsingMode | undefined,
): ProjectServiceSettings {
+ const options = typeof optionsRaw === 'object' ? optionsRaw : {};
+ validateDefaultProjectForFilesGlob(options);
+
// We import this lazily to avoid its cost for users who don't use the service
// TODO: Once we drop support for TS<5.3 we can import from "typescript" directly
const tsserver = require('typescript/lib/tsserverlibrary') as typeof ts;
@@ -60,7 +67,7 @@ export function createProjectService(
jsDocParsingMode,
});
- if (typeof options === 'object' && options.defaultProject) {
+ if (options.defaultProject) {
let configRead;
try {
@@ -97,10 +104,10 @@ export function createProjectService(
}
return {
- allowDefaultProjectForFiles:
- typeof options === 'object'
- ? options.allowDefaultProjectForFiles
- : undefined,
+ allowDefaultProjectForFiles: options.allowDefaultProjectForFiles,
+ maximumDefaultProjectFileMatchCount:
+ options.maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING ??
+ DEFAULT_PROJECT_MATCHED_FILES_THRESHOLD,
service,
};
}
diff --git a/packages/typescript-estree/src/create-program/validateDefaultProjectForFilesGlob.ts b/packages/typescript-estree/src/create-program/validateDefaultProjectForFilesGlob.ts
new file mode 100644
index 000000000000..598982bdc8e3
--- /dev/null
+++ b/packages/typescript-estree/src/create-program/validateDefaultProjectForFilesGlob.ts
@@ -0,0 +1,29 @@
+import type { ProjectServiceOptions } from '../parser-options';
+
+export const DEFAULT_PROJECT_FILES_ERROR_EXPLANATION = `
+
+Having many files run with the default project is known to cause performance issues and slow down linting.
+
+See https://typescript-eslint.io/troubleshooting/#allowdefaultprojectforfiles-glob-too-wide
+`;
+
+export function validateDefaultProjectForFilesGlob(
+ options: ProjectServiceOptions,
+): void {
+ if (!options.allowDefaultProjectForFiles?.length) {
+ return;
+ }
+
+ for (const glob of options.allowDefaultProjectForFiles) {
+ if (glob === '*') {
+ throw new Error(
+ `allowDefaultProjectForFiles contains the overly wide '*'.${DEFAULT_PROJECT_FILES_ERROR_EXPLANATION}`,
+ );
+ }
+ if (glob.includes('**')) {
+ throw new Error(
+ `allowDefaultProjectForFiles glob '${glob}' contains a disallowed '**'.${DEFAULT_PROJECT_FILES_ERROR_EXPLANATION}`,
+ );
+ }
+ }
+}
diff --git a/packages/typescript-estree/src/parser-options.ts b/packages/typescript-estree/src/parser-options.ts
index b345dd72f030..8211f40f5446 100644
--- a/packages/typescript-estree/src/parser-options.ts
+++ b/packages/typescript-estree/src/parser-options.ts
@@ -114,6 +114,15 @@ export interface ProjectServiceOptions {
* Path to a TSConfig to use instead of TypeScript's default project configuration.
*/
defaultProject?: string;
+
+ /**
+ * The maximum number of files {@link allowDefaultProjectForFiles} may match.
+ * Each file match slows down linting, so if you do need to use this, please
+ * file an informative issue on typescript-eslint explaining why - so we can
+ * help you avoid using it!
+ * @default 8
+ */
+ maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING?: number;
}
interface ParseAndGenerateServicesOptions extends ParseOptions {
diff --git a/packages/typescript-estree/src/parser.ts b/packages/typescript-estree/src/parser.ts
index ffa0c4212295..b4931a6da36e 100644
--- a/packages/typescript-estree/src/parser.ts
+++ b/packages/typescript-estree/src/parser.ts
@@ -40,6 +40,11 @@ function clearProgramCache(): void {
existingPrograms.clear();
}
+const defaultProjectMatchedFiles = new Set();
+function clearDefaultProjectMatchedFiles(): void {
+ defaultProjectMatchedFiles.clear();
+}
+
/**
* @param parseSettings Internal settings for parsing the file
* @param hasFullTypeInformation True if the program should be attempted to be calculated from provided tsconfig files
@@ -54,6 +59,7 @@ function getProgramAndAST(
parseSettings.EXPERIMENTAL_projectService,
parseSettings,
hasFullTypeInformation,
+ defaultProjectMatchedFiles,
);
if (fromProjectService) {
return fromProjectService;
@@ -286,6 +292,7 @@ export {
parse,
parseAndGenerateServices,
ParseAndGenerateServicesResult,
+ clearDefaultProjectMatchedFiles,
clearProgramCache,
clearParseAndGenerateServicesCalls,
};
diff --git a/packages/typescript-estree/src/useProgramFromProjectService.ts b/packages/typescript-estree/src/useProgramFromProjectService.ts
index ba80e5599024..8f18b08ed788 100644
--- a/packages/typescript-estree/src/useProgramFromProjectService.ts
+++ b/packages/typescript-estree/src/useProgramFromProjectService.ts
@@ -5,6 +5,7 @@ import path from 'path';
import { createProjectProgram } from './create-program/createProjectProgram';
import type { ProjectServiceSettings } from './create-program/createProjectService';
import type { ASTAndDefiniteProgram } from './create-program/shared';
+import { DEFAULT_PROJECT_FILES_ERROR_EXPLANATION } from './create-program/validateDefaultProjectForFilesGlob';
import type { MutableParseSettings } from './parseSettings';
const log = debug(
@@ -12,9 +13,14 @@ const log = debug(
);
export function useProgramFromProjectService(
- { allowDefaultProjectForFiles, service }: ProjectServiceSettings,
+ {
+ allowDefaultProjectForFiles,
+ maximumDefaultProjectFileMatchCount,
+ service,
+ }: ProjectServiceSettings,
parseSettings: Readonly,
hasFullTypeInformation: boolean,
+ defaultProjectMatchedFiles: Set,
): ASTAndDefiniteProgram | undefined {
// We don't canonicalize the filename because it caused a performance regression.
// See https://github.com/typescript-eslint/typescript-eslint/issues/8519
@@ -77,6 +83,20 @@ export function useProgramFromProjectService(
return undefined;
}
+ defaultProjectMatchedFiles.add(filePathAbsolute);
+ if (defaultProjectMatchedFiles.size > maximumDefaultProjectFileMatchCount) {
+ throw new Error(
+ `Too many files (>${maximumDefaultProjectFileMatchCount}) have matched the default project.${DEFAULT_PROJECT_FILES_ERROR_EXPLANATION}
+Matching files:
+${Array.from(defaultProjectMatchedFiles)
+ .map(file => `- ${file}`)
+ .join('\n')}
+
+If you absolutely need more files included, set parserOptions.EXPERIMENTAL_useProjectService.maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING to a larger value.
+`,
+ );
+ }
+
log('Found project service program for: %s', filePathAbsolute);
return createProjectProgram(parseSettings, [program]);
diff --git a/packages/typescript-estree/tests/lib/persistentParse.test.ts b/packages/typescript-estree/tests/lib/persistentParse.test.ts
index dbfd2831dea5..2a55b63f09a0 100644
--- a/packages/typescript-estree/tests/lib/persistentParse.test.ts
+++ b/packages/typescript-estree/tests/lib/persistentParse.test.ts
@@ -4,7 +4,10 @@ import tmp from 'tmp';
import { clearCaches } from '../../src/clear-caches';
import { clearWatchCaches } from '../../src/create-program/getWatchProgramsForProjects';
-import { parseAndGenerateServices } from '../../src/parser';
+import {
+ clearDefaultProjectMatchedFiles,
+ parseAndGenerateServices,
+} from '../../src/parser';
const CONTENTS = {
foo: 'console.log("foo")',
@@ -19,6 +22,9 @@ const CONTENTS = {
const cwdCopy = process.cwd();
const tmpDirs = new Set();
afterEach(() => {
+ // reset project tracking
+ clearDefaultProjectMatchedFiles();
+
// stop watching the files and folders
clearWatchCaches();
diff --git a/packages/typescript-estree/tests/lib/useProgramFromProjectService.test.ts b/packages/typescript-estree/tests/lib/useProgramFromProjectService.test.ts
index cec730d1cf74..5b4a9b8cabba 100644
--- a/packages/typescript-estree/tests/lib/useProgramFromProjectService.test.ts
+++ b/packages/typescript-estree/tests/lib/useProgramFromProjectService.test.ts
@@ -1,7 +1,10 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type -- Fancy mocks */
import path from 'path';
-import type { TypeScriptProjectService } from '../../src/create-program/createProjectService';
+import type {
+ ProjectServiceSettings,
+ TypeScriptProjectService,
+} from '../../src/create-program/createProjectService';
import type { ParseSettings } from '../../src/parseSettings';
import { useProgramFromProjectService } from '../../src/useProgramFromProjectService';
@@ -42,14 +45,27 @@ const mockParseSettings = {
filePath: 'path/PascalCaseDirectory/camelCaseFile.ts',
} as ParseSettings;
+const createProjectServiceSettings = <
+ T extends Partial,
+>(
+ settings: T,
+) => ({
+ maximumDefaultProjectFileMatchCount: 8,
+ ...settings,
+});
+
describe('useProgramFromProjectService', () => {
it('passes an absolute, case-matching file path to service.openClientFile', () => {
const { service } = createMockProjectService();
useProgramFromProjectService(
- { allowDefaultProjectForFiles: undefined, service },
+ createProjectServiceSettings({
+ allowDefaultProjectForFiles: undefined,
+ service,
+ }),
mockParseSettings,
false,
+ new Set(),
);
expect(service.openClientFile).toHaveBeenCalledWith(
@@ -69,9 +85,14 @@ describe('useProgramFromProjectService', () => {
expect(() =>
useProgramFromProjectService(
- { allowDefaultProjectForFiles: [mockParseSettings.filePath], service },
+ {
+ allowDefaultProjectForFiles: [mockParseSettings.filePath],
+ maximumDefaultProjectFileMatchCount: 8,
+ service,
+ },
mockParseSettings,
true,
+ new Set(),
),
).toThrow(
`${mockParseSettings.filePath} was included by allowDefaultProjectForFiles but also was found in the project service. Consider removing it from allowDefaultProjectForFiles.`,
@@ -85,28 +106,70 @@ describe('useProgramFromProjectService', () => {
expect(() =>
useProgramFromProjectService(
- { allowDefaultProjectForFiles: [], service },
+ createProjectServiceSettings({
+ allowDefaultProjectForFiles: [],
+ service,
+ }),
mockParseSettings,
true,
+ new Set(),
),
).toThrow(
`${mockParseSettings.filePath} was not found by the project service. Consider either including it in the tsconfig.json or including it in allowDefaultProjectForFiles.`,
);
});
+ it('throws an error when called more than the maximum allowed file count', () => {
+ const { service } = createMockProjectService();
+ const program = { getSourceFile: jest.fn() };
+
+ mockGetProgram.mockReturnValueOnce(program);
+
+ service.openClientFile.mockReturnValueOnce({});
+
+ expect(() =>
+ useProgramFromProjectService(
+ createProjectServiceSettings({
+ allowDefaultProjectForFiles: [mockParseSettings.filePath],
+ maximumDefaultProjectFileMatchCount: 2,
+ service,
+ }),
+ mockParseSettings,
+ true,
+ new Set(['a', 'b']),
+ ),
+ ).toThrow(`Too many files (>2) have matched the default project.
+
+Having many files run with the default project is known to cause performance issues and slow down linting.
+
+See https://typescript-eslint.io/troubleshooting/#allowdefaultprojectforfiles-glob-too-wide
+
+Matching files:
+- a
+- b
+- ${path.normalize('/repos/repo/path/PascalCaseDirectory/camelCaseFile.ts')}
+
+If you absolutely need more files included, set parserOptions.EXPERIMENTAL_useProjectService.maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING to a larger value.
+`);
+ });
+
it('returns undefined when hasFullTypeInformation is disabled, the file is both in the project service and allowDefaultProjectForFiles, and the service does not have a matching program', () => {
const { service } = createMockProjectService();
- mockGetProgram.mockReturnValue(undefined);
+ mockGetProgram.mockReturnValueOnce(undefined);
service.openClientFile.mockReturnValueOnce({
configFileName: 'tsconfig.json',
});
const actual = useProgramFromProjectService(
- { allowDefaultProjectForFiles: [mockParseSettings.filePath], service },
+ createProjectServiceSettings({
+ allowDefaultProjectForFiles: [mockParseSettings.filePath],
+ service,
+ }),
mockParseSettings,
false,
+ new Set(),
);
expect(actual).toBeUndefined();
@@ -116,7 +179,7 @@ describe('useProgramFromProjectService', () => {
const { service } = createMockProjectService();
const program = { getSourceFile: jest.fn() };
- mockGetProgram.mockReturnValue(program);
+ mockGetProgram.mockReturnValueOnce(program);
service.openClientFile.mockReturnValueOnce({
configFileName: 'tsconfig.json',
@@ -124,9 +187,13 @@ describe('useProgramFromProjectService', () => {
mockCreateProjectProgram.mockReturnValueOnce(program);
const actual = useProgramFromProjectService(
- { allowDefaultProjectForFiles: [mockParseSettings.filePath], service },
+ createProjectServiceSettings({
+ allowDefaultProjectForFiles: [mockParseSettings.filePath],
+ service,
+ }),
mockParseSettings,
false,
+ new Set(),
);
expect(actual).toBe(program);
@@ -136,15 +203,19 @@ describe('useProgramFromProjectService', () => {
const { service } = createMockProjectService();
const program = { getSourceFile: jest.fn() };
- mockGetProgram.mockReturnValue(program);
+ mockGetProgram.mockReturnValueOnce(program);
service.openClientFile.mockReturnValueOnce({});
mockCreateProjectProgram.mockReturnValueOnce(program);
const actual = useProgramFromProjectService(
- { allowDefaultProjectForFiles: [], service },
+ createProjectServiceSettings({
+ allowDefaultProjectForFiles: [],
+ service,
+ }),
mockParseSettings,
false,
+ new Set(),
);
expect(actual).toBe(program);
diff --git a/packages/typescript-estree/tests/lib/validateDefaultProjectForFilesGlob.test.ts b/packages/typescript-estree/tests/lib/validateDefaultProjectForFilesGlob.test.ts
new file mode 100644
index 000000000000..6510182fefd9
--- /dev/null
+++ b/packages/typescript-estree/tests/lib/validateDefaultProjectForFilesGlob.test.ts
@@ -0,0 +1,35 @@
+import { validateDefaultProjectForFilesGlob } from '../../src/create-program/validateDefaultProjectForFilesGlob';
+
+describe('validateDefaultProjectForFilesGlob', () => {
+ it('does not throw when options.allowDefaultProjectForFiles is an empty array', () => {
+ expect(() =>
+ validateDefaultProjectForFilesGlob({ allowDefaultProjectForFiles: [] }),
+ ).not.toThrow();
+ });
+
+ it('does not throw when options.allowDefaultProjectForFiles contains a non-** glob', () => {
+ expect(() =>
+ validateDefaultProjectForFilesGlob({
+ allowDefaultProjectForFiles: ['./*.js'],
+ }),
+ ).not.toThrow();
+ });
+
+ it('throws when options.allowDefaultProjectForFiles contains a * glob', () => {
+ expect(() =>
+ validateDefaultProjectForFilesGlob({
+ allowDefaultProjectForFiles: ['*'],
+ }),
+ ).toThrow(/allowDefaultProjectForFiles contains the overly wide '\*'\./);
+ });
+
+ it('throws when options.allowDefaultProjectForFiles contains a ** glob', () => {
+ expect(() =>
+ validateDefaultProjectForFilesGlob({
+ allowDefaultProjectForFiles: ['**/*.js'],
+ }),
+ ).toThrow(
+ /allowDefaultProjectForFiles glob '\*\*\/\*\.js' contains a disallowed '\*\*'\./,
+ );
+ });
+});
diff --git a/packages/website/src/theme/MDXComponents/HiddenHeading.module.css b/packages/website/src/theme/MDXComponents/HiddenHeading.module.css
new file mode 100644
index 000000000000..79c1e7a6e7d3
--- /dev/null
+++ b/packages/website/src/theme/MDXComponents/HiddenHeading.module.css
@@ -0,0 +1,6 @@
+.hiddenHeading {
+ display: block;
+ position: relative;
+ top: calc(-1 * var(--ifm-navbar-height));
+ visibility: hidden;
+}
diff --git a/packages/website/src/theme/MDXComponents/HiddenHeading.tsx b/packages/website/src/theme/MDXComponents/HiddenHeading.tsx
new file mode 100644
index 000000000000..24ff9052f01d
--- /dev/null
+++ b/packages/website/src/theme/MDXComponents/HiddenHeading.tsx
@@ -0,0 +1,11 @@
+import React from 'react';
+
+import styles from './HiddenHeading.module.css';
+
+export interface HiddenHeadingProps {
+ id: string;
+}
+
+export function HiddenHeading({ id }: HiddenHeadingProps): React.JSX.Element {
+ return ;
+}
diff --git a/packages/website/src/theme/MDXComponents/index.tsx b/packages/website/src/theme/MDXComponents/index.tsx
index f84c301edc35..787837995963 100644
--- a/packages/website/src/theme/MDXComponents/index.tsx
+++ b/packages/website/src/theme/MDXComponents/index.tsx
@@ -2,13 +2,15 @@ import Admonition from '@theme/Admonition';
import MDXComponents from '@theme-original/MDXComponents';
import { BaseRuleReference } from './BaseRuleReference';
+import { HiddenHeading } from './HiddenHeading';
import { RuleAttributes } from './RuleAttributes';
import { TryInPlayground } from './TryInPlayground';
export default {
...MDXComponents,
+ Admonition,
BaseRuleReference,
+ HiddenHeading,
RuleAttributes,
TryInPlayground,
- Admonition,
};