Skip to content

Commit e54d78c

Browse files
JoshuaKGoldbergauvred
authored andcommitted
feat(typescript-estree): add allowDefaultProjectForFiles project service allowlist option (typescript-eslint#7752)
* fix(typescript-estree): allow project service for unknown client file * Fixed up project throwing tests * Added allowDefaultProjectForFiles as nested option * Added a bit more testing * fix: only throw on missing file path if hasFullTypeInformation * Fix test snapshot * Remove unnecessary assertion * TypeScript_ESTree.mdx docs * Absolute and canonical file paths * Use minimatch instead of fs globbing * lint: import sorting, missing return type * Ignore some tests
1 parent 483d1b8 commit e54d78c

File tree

12 files changed

+203
-105
lines changed

12 files changed

+203
-105
lines changed

Diff for: docs/packages/TypeScript_ESTree.mdx

+11-1
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ interface ParseAndGenerateServicesOptions extends ParseOptions {
167167
*
168168
* @see https://github.com/typescript-eslint/typescript-eslint/issues/6575
169169
*/
170-
EXPERIMENTAL_useProjectService?: boolean;
170+
EXPERIMENTAL_useProjectService?: boolean | ProjectServiceOptions;
171171

172172
/**
173173
* ***EXPERIMENTAL FLAG*** - Use this at your own risk.
@@ -270,6 +270,16 @@ interface ParseAndGenerateServicesOptions extends ParseOptions {
270270
};
271271
}
272272

273+
/**
274+
* Granular options to configure the project service.
275+
*/
276+
interface ProjectServiceOptions {
277+
/**
278+
* Globs of files to allow running with the default inferred project settings.
279+
*/
280+
allowDefaultProjectForFiles?: string[];
281+
}
282+
273283
interface ParserServices {
274284
program: ts.Program;
275285
esTreeNodeToTSNodeMap: WeakMap<TSESTree.Node, ts.Node | ts.Token>;

Diff for: packages/eslint-plugin-tslint/tests/index.spec.ts

+64-62
Original file line numberDiff line numberDiff line change
@@ -166,71 +166,73 @@ ruleTester.run('tslint/config', rule, {
166166
],
167167
});
168168

169-
describe('tslint/error', () => {
170-
function testOutput(code: string, config: ClassicConfig.Config): void {
171-
const linter = new TSESLint.Linter();
172-
linter.defineRule('tslint/config', rule);
173-
linter.defineParser('@typescript-eslint/parser', parser);
174-
175-
expect(() => linter.verify(code, config)).toThrow(
176-
'You have used a rule which requires parserServices to be generated. You must therefore provide a value for the "parserOptions.project" property for @typescript-eslint/parser.',
177-
);
178-
}
179-
180-
it('should error on missing project', () => {
181-
testOutput('foo;', {
182-
rules: {
183-
'tslint/config': [2, tslintRulesConfig],
184-
},
185-
parser: '@typescript-eslint/parser',
169+
if (process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'true') {
170+
describe('tslint/error', () => {
171+
function testOutput(code: string, config: ClassicConfig.Config): void {
172+
const linter = new TSESLint.Linter();
173+
linter.defineRule('tslint/config', rule);
174+
linter.defineParser('@typescript-eslint/parser', parser);
175+
176+
expect(() => linter.verify(code, config)).toThrow(
177+
'You have used a rule which requires parserServices to be generated. You must therefore provide a value for the "parserOptions.project" property for @typescript-eslint/parser.',
178+
);
179+
}
180+
181+
it('should error on missing project', () => {
182+
testOutput('foo;', {
183+
rules: {
184+
'tslint/config': [2, tslintRulesConfig],
185+
},
186+
parser: '@typescript-eslint/parser',
187+
});
186188
});
187-
});
188189

189-
it('should error on default parser', () => {
190-
testOutput('foo;', {
191-
parserOptions: {
192-
project: TEST_PROJECT_PATH,
193-
},
194-
rules: {
195-
'tslint/config': [2, tslintRulesConfig],
196-
},
190+
it('should error on default parser', () => {
191+
testOutput('foo;', {
192+
parserOptions: {
193+
project: TEST_PROJECT_PATH,
194+
},
195+
rules: {
196+
'tslint/config': [2, tslintRulesConfig],
197+
},
198+
});
197199
});
198-
});
199200

200-
it('should not crash if there are no tslint rules specified', () => {
201-
const linter = new TSESLint.Linter();
202-
jest.spyOn(console, 'warn').mockImplementation();
203-
linter.defineRule('tslint/config', rule);
204-
linter.defineParser('@typescript-eslint/parser', parser);
205-
206-
const filePath = path.resolve(
207-
__dirname,
208-
'fixtures',
209-
'test-project',
210-
'extra.ts',
211-
);
212-
213-
expect(() =>
214-
linter.verify(
215-
'foo;',
216-
{
217-
parserOptions: {
218-
project: TEST_PROJECT_PATH,
219-
},
220-
rules: {
221-
'tslint/config': [2, {}],
201+
it('should not crash if there are no tslint rules specified', () => {
202+
const linter = new TSESLint.Linter();
203+
jest.spyOn(console, 'warn').mockImplementation();
204+
linter.defineRule('tslint/config', rule);
205+
linter.defineParser('@typescript-eslint/parser', parser);
206+
207+
const filePath = path.resolve(
208+
__dirname,
209+
'fixtures',
210+
'test-project',
211+
'extra.ts',
212+
);
213+
214+
expect(() =>
215+
linter.verify(
216+
'foo;',
217+
{
218+
parserOptions: {
219+
project: TEST_PROJECT_PATH,
220+
},
221+
rules: {
222+
'tslint/config': [2, {}],
223+
},
224+
parser: '@typescript-eslint/parser',
222225
},
223-
parser: '@typescript-eslint/parser',
224-
},
225-
filePath,
226-
),
227-
).not.toThrow();
228-
229-
expect(console.warn).toHaveBeenCalledWith(
230-
expect.stringContaining(
231-
`Tried to lint ${filePath} but found no valid, enabled rules for this file type and file path in the resolved configuration.`,
232-
),
233-
);
234-
jest.resetAllMocks();
226+
filePath,
227+
),
228+
).not.toThrow();
229+
230+
expect(console.warn).toHaveBeenCalledWith(
231+
expect.stringContaining(
232+
`Tried to lint ${filePath} but found no valid, enabled rules for this file type and file path in the resolved configuration.`,
233+
),
234+
);
235+
jest.resetAllMocks();
236+
});
235237
});
236-
});
238+
}

Diff for: packages/typescript-estree/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"debug": "^4.3.4",
5858
"globby": "^11.1.0",
5959
"is-glob": "^4.0.3",
60+
"minimatch": "9.0.3",
6061
"semver": "^7.5.4",
6162
"ts-api-utils": "^1.0.1"
6263
},

Diff for: packages/typescript-estree/src/create-program/createProjectService.ts

+20-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
/* eslint-disable @typescript-eslint/no-empty-function -- for TypeScript APIs*/
12
import type * as ts from 'typescript/lib/tsserverlibrary';
23

3-
// eslint-disable-next-line @typescript-eslint/no-empty-function
4+
import type { ProjectServiceOptions } from '../parser-options';
5+
46
const doNothing = (): void => {};
57

68
const createStubFileWatcher = (): ts.FileWatcher => ({
@@ -9,9 +11,15 @@ const createStubFileWatcher = (): ts.FileWatcher => ({
911

1012
export type TypeScriptProjectService = ts.server.ProjectService;
1113

14+
export interface ProjectServiceSettings {
15+
allowDefaultProjectForFiles: string[] | undefined;
16+
service: TypeScriptProjectService;
17+
}
18+
1219
export function createProjectService(
13-
jsDocParsingMode?: ts.JSDocParsingMode,
14-
): TypeScriptProjectService {
20+
options: boolean | ProjectServiceOptions | undefined,
21+
jsDocParsingMode: ts.JSDocParsingMode | undefined,
22+
): ProjectServiceSettings {
1523
// We import this lazily to avoid its cost for users who don't use the service
1624
// TODO: Once we drop support for TS<5.3 we can import from "typescript" directly
1725
const tsserver = require('typescript/lib/tsserverlibrary') as typeof ts;
@@ -30,7 +38,7 @@ export function createProjectService(
3038
watchFile: createStubFileWatcher,
3139
};
3240

33-
return new tsserver.server.ProjectService({
41+
const service = new tsserver.server.ProjectService({
3442
host: system,
3543
cancellationToken: { isCancellationRequested: (): boolean => false },
3644
useSingleInferredProject: false,
@@ -49,4 +57,12 @@ export function createProjectService(
4957
session: undefined,
5058
jsDocParsingMode,
5159
});
60+
61+
return {
62+
allowDefaultProjectForFiles:
63+
typeof options === 'object'
64+
? options.allowDefaultProjectForFiles
65+
: undefined,
66+
service,
67+
};
5268
}

Diff for: packages/typescript-estree/src/parseSettings/createParseSettings.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import debug from 'debug';
22
import * as ts from 'typescript';
33

4-
import type { TypeScriptProjectService } from '../create-program/createProjectService';
4+
import type { ProjectServiceSettings } from '../create-program/createProjectService';
55
import { createProjectService } from '../create-program/createProjectService';
66
import { ensureAbsolutePath } from '../create-program/shared';
77
import type { TSESTreeOptions } from '../parser-options';
@@ -21,7 +21,7 @@ const log = debug(
2121
);
2222

2323
let TSCONFIG_MATCH_CACHE: ExpiringCache<string, string> | null;
24-
let TSSERVER_PROJECT_SERVICE: TypeScriptProjectService | null = null;
24+
let TSSERVER_PROJECT_SERVICE: ProjectServiceSettings | null = null;
2525

2626
// NOTE - we intentionally use "unnecessary" `?.` here because in TS<5.3 this enum doesn't exist
2727
// This object exists so we can centralize these for tracking and so we don't proliferate these across the file
@@ -80,11 +80,14 @@ export function createParseSettings(
8080
errorOnTypeScriptSyntacticAndSemanticIssues: false,
8181
errorOnUnknownASTType: options.errorOnUnknownASTType === true,
8282
EXPERIMENTAL_projectService:
83-
(options.EXPERIMENTAL_useProjectService === true &&
83+
(options.EXPERIMENTAL_useProjectService &&
8484
process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'false') ||
8585
(process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER === 'true' &&
8686
options.EXPERIMENTAL_useProjectService !== false)
87-
? (TSSERVER_PROJECT_SERVICE ??= createProjectService(jsDocParsingMode))
87+
? (TSSERVER_PROJECT_SERVICE ??= createProjectService(
88+
options.EXPERIMENTAL_useProjectService,
89+
jsDocParsingMode,
90+
))
8891
: undefined,
8992
EXPERIMENTAL_useSourceOfProjectReferenceRedirect:
9093
options.EXPERIMENTAL_useSourceOfProjectReferenceRedirect === true,

Diff for: packages/typescript-estree/src/parseSettings/index.ts

+2-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type * as ts from 'typescript';
2-
import type * as tsserverlibrary from 'typescript/lib/tsserverlibrary';
32

3+
import type { ProjectServiceSettings } from '../create-program/createProjectService';
44
import type { CanonicalPath } from '../create-program/shared';
55
import type { TSESTree } from '../ts-estree';
66
import type { CacheLike } from './ExpiringCache';
@@ -67,9 +67,7 @@ export interface MutableParseSettings {
6767
/**
6868
* Experimental: TypeScript server to power program creation.
6969
*/
70-
EXPERIMENTAL_projectService:
71-
| tsserverlibrary.server.ProjectService
72-
| undefined;
70+
EXPERIMENTAL_projectService: ProjectServiceSettings | undefined;
7371

7472
/**
7573
* Whether TS should use the source files for referenced projects instead of the compiled .d.ts files.

Diff for: packages/typescript-estree/src/parser-options.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,16 @@ interface ParseOptions {
101101
suppressDeprecatedPropertyWarnings?: boolean;
102102
}
103103

104+
/**
105+
* Granular options to configure the project service.
106+
*/
107+
export interface ProjectServiceOptions {
108+
/**
109+
* Globs of files to allow running with the default inferred project settings.
110+
*/
111+
allowDefaultProjectForFiles?: string[];
112+
}
113+
104114
interface ParseAndGenerateServicesOptions extends ParseOptions {
105115
/**
106116
* Causes the parser to error if the TypeScript compiler returns any unexpected syntax/semantic errors.
@@ -114,7 +124,7 @@ interface ParseAndGenerateServicesOptions extends ParseOptions {
114124
*
115125
* @see https://github.com/typescript-eslint/typescript-eslint/issues/6575
116126
*/
117-
EXPERIMENTAL_useProjectService?: boolean;
127+
EXPERIMENTAL_useProjectService?: boolean | ProjectServiceOptions;
118128

119129
/**
120130
* ***EXPERIMENTAL FLAG*** - Use this at your own risk.

Diff for: packages/typescript-estree/src/parser.ts

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ function getProgramAndAST(
5353
const fromProjectService = useProgramFromProjectService(
5454
parseSettings.EXPERIMENTAL_projectService,
5555
parseSettings,
56+
hasFullTypeInformation,
5657
);
5758
if (fromProjectService) {
5859
return fromProjectService;
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,44 @@
1-
import path from 'path';
2-
import type { server } from 'typescript/lib/tsserverlibrary';
1+
import { minimatch } from 'minimatch';
32

43
import { createProjectProgram } from './create-program/createProjectProgram';
5-
import { type ASTAndDefiniteProgram } from './create-program/shared';
4+
import type { ProjectServiceSettings } from './create-program/createProjectService';
5+
import {
6+
type ASTAndDefiniteProgram,
7+
ensureAbsolutePath,
8+
getCanonicalFileName,
9+
} from './create-program/shared';
610
import type { MutableParseSettings } from './parseSettings';
711

812
export function useProgramFromProjectService(
9-
projectService: server.ProjectService,
13+
{ allowDefaultProjectForFiles, service }: ProjectServiceSettings,
1014
parseSettings: Readonly<MutableParseSettings>,
15+
hasFullTypeInformation: boolean,
1116
): ASTAndDefiniteProgram | undefined {
12-
const opened = projectService.openClientFile(
13-
absolutify(parseSettings.filePath),
17+
const filePath = getCanonicalFileName(parseSettings.filePath);
18+
19+
const opened = service.openClientFile(
20+
ensureAbsolutePath(filePath, service.host.getCurrentDirectory()),
1421
parseSettings.codeFullText,
1522
/* scriptKind */ undefined,
1623
parseSettings.tsconfigRootDir,
1724
);
18-
if (!opened.configFileName) {
19-
return undefined;
25+
26+
if (hasFullTypeInformation) {
27+
if (opened.configFileName) {
28+
if (filePathMatchedBy(filePath, allowDefaultProjectForFiles)) {
29+
throw new Error(
30+
`${filePath} was included by allowDefaultProjectForFiles but also was found in the project service. Consider removing it from allowDefaultProjectForFiles.`,
31+
);
32+
}
33+
} else if (!filePathMatchedBy(filePath, allowDefaultProjectForFiles)) {
34+
throw new Error(
35+
`${filePath} was not found by the project service. Consider either including it in the tsconfig.json or including it in allowDefaultProjectForFiles.`,
36+
);
37+
}
2038
}
2139

22-
const scriptInfo = projectService.getScriptInfo(parseSettings.filePath);
23-
const program = projectService
40+
const scriptInfo = service.getScriptInfo(filePath);
41+
const program = service
2442
.getDefaultProjectForFile(scriptInfo!.fileName, true)!
2543
.getLanguageService(/*ensureSynchronized*/ true)
2644
.getProgram();
@@ -30,10 +48,13 @@ export function useProgramFromProjectService(
3048
}
3149

3250
return createProjectProgram(parseSettings, [program]);
51+
}
3352

34-
function absolutify(filePath: string): string {
35-
return path.isAbsolute(filePath)
36-
? filePath
37-
: path.join(projectService.host.getCurrentDirectory(), filePath);
38-
}
53+
function filePathMatchedBy(
54+
filePath: string,
55+
allowDefaultProjectForFiles: string[] | undefined,
56+
): boolean {
57+
return !!allowDefaultProjectForFiles?.some(pattern =>
58+
minimatch(filePath, pattern),
59+
);
3960
}

0 commit comments

Comments
 (0)