Skip to content

feat(typescript-estree): add allowDefaultProjectForFiles project service allowlist option #7752

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/packages/TypeScript_ESTree.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ interface ParseAndGenerateServicesOptions extends ParseOptions {
*
* @see https://github.com/typescript-eslint/typescript-eslint/issues/6575
*/
EXPERIMENTAL_useProjectService?: boolean;
EXPERIMENTAL_useProjectService?: boolean | ProjectServiceOptions;

/**
* ***EXPERIMENTAL FLAG*** - Use this at your own risk.
Expand Down Expand Up @@ -270,6 +270,16 @@ interface ParseAndGenerateServicesOptions extends ParseOptions {
};
}

/**
* Granular options to configure the project service.
*/
interface ProjectServiceOptions {
/**
* Globs of files to allow running with the default inferred project settings.
*/
allowDefaultProjectForFiles?: string[];
}

interface ParserServices {
program: ts.Program;
esTreeNodeToTSNodeMap: WeakMap<TSESTree.Node, ts.Node | ts.Token>;
Expand Down
126 changes: 64 additions & 62 deletions packages/eslint-plugin-tslint/tests/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,71 +166,73 @@ ruleTester.run('tslint/config', rule, {
],
});

describe('tslint/error', () => {
function testOutput(code: string, config: ClassicConfig.Config): void {
const linter = new TSESLint.Linter();
linter.defineRule('tslint/config', rule);
linter.defineParser('@typescript-eslint/parser', parser);

expect(() => linter.verify(code, config)).toThrow(
'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.',
);
}

it('should error on missing project', () => {
testOutput('foo;', {
rules: {
'tslint/config': [2, tslintRulesConfig],
},
parser: '@typescript-eslint/parser',
if (process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'true') {
describe('tslint/error', () => {
function testOutput(code: string, config: ClassicConfig.Config): void {
const linter = new TSESLint.Linter();
linter.defineRule('tslint/config', rule);
linter.defineParser('@typescript-eslint/parser', parser);

expect(() => linter.verify(code, config)).toThrow(
'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.',
);
}

it('should error on missing project', () => {
testOutput('foo;', {
rules: {
'tslint/config': [2, tslintRulesConfig],
},
parser: '@typescript-eslint/parser',
});
});
});

it('should error on default parser', () => {
testOutput('foo;', {
parserOptions: {
project: TEST_PROJECT_PATH,
},
rules: {
'tslint/config': [2, tslintRulesConfig],
},
it('should error on default parser', () => {
testOutput('foo;', {
parserOptions: {
project: TEST_PROJECT_PATH,
},
rules: {
'tslint/config': [2, tslintRulesConfig],
},
});
});
});

it('should not crash if there are no tslint rules specified', () => {
const linter = new TSESLint.Linter();
jest.spyOn(console, 'warn').mockImplementation();
linter.defineRule('tslint/config', rule);
linter.defineParser('@typescript-eslint/parser', parser);

const filePath = path.resolve(
__dirname,
'fixtures',
'test-project',
'extra.ts',
);

expect(() =>
linter.verify(
'foo;',
{
parserOptions: {
project: TEST_PROJECT_PATH,
},
rules: {
'tslint/config': [2, {}],
it('should not crash if there are no tslint rules specified', () => {
const linter = new TSESLint.Linter();
jest.spyOn(console, 'warn').mockImplementation();
linter.defineRule('tslint/config', rule);
linter.defineParser('@typescript-eslint/parser', parser);

const filePath = path.resolve(
__dirname,
'fixtures',
'test-project',
'extra.ts',
);

expect(() =>
linter.verify(
'foo;',
{
parserOptions: {
project: TEST_PROJECT_PATH,
},
rules: {
'tslint/config': [2, {}],
},
parser: '@typescript-eslint/parser',
},
parser: '@typescript-eslint/parser',
},
filePath,
),
).not.toThrow();

expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining(
`Tried to lint ${filePath} but found no valid, enabled rules for this file type and file path in the resolved configuration.`,
),
);
jest.resetAllMocks();
filePath,
),
).not.toThrow();

expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining(
`Tried to lint ${filePath} but found no valid, enabled rules for this file type and file path in the resolved configuration.`,
),
);
jest.resetAllMocks();
});
});
});
}
1 change: 1 addition & 0 deletions packages/typescript-estree/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
"minimatch": "9.0.3",
"semver": "^7.5.4",
"ts-api-utils": "^1.0.1"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* eslint-disable @typescript-eslint/no-empty-function -- for TypeScript APIs*/
import type * as ts from 'typescript/lib/tsserverlibrary';

// eslint-disable-next-line @typescript-eslint/no-empty-function
import type { ProjectServiceOptions } from '../parser-options';

const doNothing = (): void => {};

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

export type TypeScriptProjectService = ts.server.ProjectService;

export interface ProjectServiceSettings {
allowDefaultProjectForFiles: string[] | undefined;
service: TypeScriptProjectService;
}

export function createProjectService(
jsDocParsingMode?: ts.JSDocParsingMode,
): TypeScriptProjectService {
options: boolean | ProjectServiceOptions | undefined,
jsDocParsingMode: ts.JSDocParsingMode | undefined,
): ProjectServiceSettings {
// 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;
Expand All @@ -30,7 +38,7 @@ export function createProjectService(
watchFile: createStubFileWatcher,
};

return new tsserver.server.ProjectService({
const service = new tsserver.server.ProjectService({
host: system,
cancellationToken: { isCancellationRequested: (): boolean => false },
useSingleInferredProject: false,
Expand All @@ -49,4 +57,12 @@ export function createProjectService(
session: undefined,
jsDocParsingMode,
});

return {
allowDefaultProjectForFiles:
typeof options === 'object'
? options.allowDefaultProjectForFiles
: undefined,
service,
};
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import debug from 'debug';
import * as ts from 'typescript';

import type { TypeScriptProjectService } from '../create-program/createProjectService';
import type { ProjectServiceSettings } from '../create-program/createProjectService';
import { createProjectService } from '../create-program/createProjectService';
import { ensureAbsolutePath } from '../create-program/shared';
import type { TSESTreeOptions } from '../parser-options';
Expand All @@ -21,7 +21,7 @@ const log = debug(
);

let TSCONFIG_MATCH_CACHE: ExpiringCache<string, string> | null;
let TSSERVER_PROJECT_SERVICE: TypeScriptProjectService | null = null;
let TSSERVER_PROJECT_SERVICE: ProjectServiceSettings | null = null;

// NOTE - we intentionally use "unnecessary" `?.` here because in TS<5.3 this enum doesn't exist
// This object exists so we can centralize these for tracking and so we don't proliferate these across the file
Expand Down Expand Up @@ -80,11 +80,14 @@ export function createParseSettings(
errorOnTypeScriptSyntacticAndSemanticIssues: false,
errorOnUnknownASTType: options.errorOnUnknownASTType === true,
EXPERIMENTAL_projectService:
(options.EXPERIMENTAL_useProjectService === true &&
(options.EXPERIMENTAL_useProjectService &&
process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER !== 'false') ||
(process.env.TYPESCRIPT_ESLINT_EXPERIMENTAL_TSSERVER === 'true' &&
options.EXPERIMENTAL_useProjectService !== false)
? (TSSERVER_PROJECT_SERVICE ??= createProjectService(jsDocParsingMode))
? (TSSERVER_PROJECT_SERVICE ??= createProjectService(
options.EXPERIMENTAL_useProjectService,
jsDocParsingMode,
))
: undefined,
EXPERIMENTAL_useSourceOfProjectReferenceRedirect:
options.EXPERIMENTAL_useSourceOfProjectReferenceRedirect === true,
Expand Down
6 changes: 2 additions & 4 deletions packages/typescript-estree/src/parseSettings/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type * as ts from 'typescript';
import type * as tsserverlibrary from 'typescript/lib/tsserverlibrary';

import type { ProjectServiceSettings } from '../create-program/createProjectService';
import type { CanonicalPath } from '../create-program/shared';
import type { TSESTree } from '../ts-estree';
import type { CacheLike } from './ExpiringCache';
Expand Down Expand Up @@ -67,9 +67,7 @@ export interface MutableParseSettings {
/**
* Experimental: TypeScript server to power program creation.
*/
EXPERIMENTAL_projectService:
| tsserverlibrary.server.ProjectService
| undefined;
EXPERIMENTAL_projectService: ProjectServiceSettings | undefined;

/**
* Whether TS should use the source files for referenced projects instead of the compiled .d.ts files.
Expand Down
12 changes: 11 additions & 1 deletion packages/typescript-estree/src/parser-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@ interface ParseOptions {
suppressDeprecatedPropertyWarnings?: boolean;
}

/**
* Granular options to configure the project service.
*/
export interface ProjectServiceOptions {
/**
* Globs of files to allow running with the default inferred project settings.
*/
allowDefaultProjectForFiles?: string[];
}

interface ParseAndGenerateServicesOptions extends ParseOptions {
/**
* Causes the parser to error if the TypeScript compiler returns any unexpected syntax/semantic errors.
Expand All @@ -114,7 +124,7 @@ interface ParseAndGenerateServicesOptions extends ParseOptions {
*
* @see https://github.com/typescript-eslint/typescript-eslint/issues/6575
*/
EXPERIMENTAL_useProjectService?: boolean;
EXPERIMENTAL_useProjectService?: boolean | ProjectServiceOptions;

/**
* ***EXPERIMENTAL FLAG*** - Use this at your own risk.
Expand Down
1 change: 1 addition & 0 deletions packages/typescript-estree/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ function getProgramAndAST(
const fromProjectService = useProgramFromProjectService(
parseSettings.EXPERIMENTAL_projectService,
parseSettings,
hasFullTypeInformation,
);
if (fromProjectService) {
return fromProjectService;
Expand Down
51 changes: 36 additions & 15 deletions packages/typescript-estree/src/useProgramFromProjectService.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,44 @@
import path from 'path';
import type { server } from 'typescript/lib/tsserverlibrary';
import { minimatch } from 'minimatch';

import { createProjectProgram } from './create-program/createProjectProgram';
import { type ASTAndDefiniteProgram } from './create-program/shared';
import type { ProjectServiceSettings } from './create-program/createProjectService';
import {
type ASTAndDefiniteProgram,
ensureAbsolutePath,
getCanonicalFileName,
} from './create-program/shared';
import type { MutableParseSettings } from './parseSettings';

export function useProgramFromProjectService(
projectService: server.ProjectService,
{ allowDefaultProjectForFiles, service }: ProjectServiceSettings,
parseSettings: Readonly<MutableParseSettings>,
hasFullTypeInformation: boolean,
): ASTAndDefiniteProgram | undefined {
const opened = projectService.openClientFile(
absolutify(parseSettings.filePath),
const filePath = getCanonicalFileName(parseSettings.filePath);

const opened = service.openClientFile(
ensureAbsolutePath(filePath, service.host.getCurrentDirectory()),
parseSettings.codeFullText,
/* scriptKind */ undefined,
parseSettings.tsconfigRootDir,
);
if (!opened.configFileName) {
return undefined;

if (hasFullTypeInformation) {
if (opened.configFileName) {
if (filePathMatchedBy(filePath, allowDefaultProjectForFiles)) {
throw new Error(
`${filePath} was included by allowDefaultProjectForFiles but also was found in the project service. Consider removing it from allowDefaultProjectForFiles.`,
);
}
} else if (!filePathMatchedBy(filePath, allowDefaultProjectForFiles)) {
throw new Error(
`${filePath} was not found by the project service. Consider either including it in the tsconfig.json or including it in allowDefaultProjectForFiles.`,
);
}
}

const scriptInfo = projectService.getScriptInfo(parseSettings.filePath);
const program = projectService
const scriptInfo = service.getScriptInfo(filePath);
const program = service
.getDefaultProjectForFile(scriptInfo!.fileName, true)!
.getLanguageService(/*ensureSynchronized*/ true)
.getProgram();
Expand All @@ -30,10 +48,13 @@ export function useProgramFromProjectService(
}

return createProjectProgram(parseSettings, [program]);
}

function absolutify(filePath: string): string {
return path.isAbsolute(filePath)
? filePath
: path.join(projectService.host.getCurrentDirectory(), filePath);
}
function filePathMatchedBy(
filePath: string,
allowDefaultProjectForFiles: string[] | undefined,
): boolean {
return !!allowDefaultProjectForFiles?.some(pattern =>
minimatch(filePath, pattern),
);
}
Loading