diff --git a/build-tests/eslint-bulk-suppressions-test-legacy/build.js b/build-tests/eslint-bulk-suppressions-test-legacy/build.js index 2e32ac207b2..662b1f42ce2 100644 --- a/build-tests/eslint-bulk-suppressions-test-legacy/build.js +++ b/build-tests/eslint-bulk-suppressions-test-legacy/build.js @@ -5,7 +5,9 @@ const { FileSystem, Executable, Text, Import } = require('@rushstack/node-core-library'); const path = require('path'); const { - ESLINT_PACKAGE_NAME_ENV_VAR_NAME + ESLINT_PACKAGE_NAME_ENV_VAR_NAME, + ESLINT_BULK_ESLINTRC_FOLDER_PATH_ENV_VAR_NAME, + ESLINT_BULK_FORCE_REGENERATE_PATCH_ENV_VAR_NAME } = require('@rushstack/eslint-patch/lib/eslint-bulk-suppressions/constants'); const eslintBulkStartPath = Import.resolveModule({ @@ -65,7 +67,7 @@ for (const runFolderPath of RUN_FOLDER_PATHS) { ); const shellPathWithEslint = `${dependencyBinFolder}${path.delimiter}${process.env['PATH']}`; - const executableResult = Executable.spawnSync( + const suppressResult = Executable.spawnSync( process.argv0, [eslintBulkStartPath, 'suppress', '--all', 'src'], { @@ -73,17 +75,18 @@ for (const runFolderPath of RUN_FOLDER_PATHS) { environment: { ...process.env, PATH: shellPathWithEslint, - [ESLINT_PACKAGE_NAME_ENV_VAR_NAME]: eslintPackageName + [ESLINT_PACKAGE_NAME_ENV_VAR_NAME]: eslintPackageName, + [ESLINT_BULK_FORCE_REGENERATE_PATCH_ENV_VAR_NAME]: 'true' } } ); - if (executableResult.status !== 0) { + if (suppressResult.status !== 0) { console.error('The eslint-bulk-suppressions command failed.'); console.error('STDOUT:'); - console.error(executableResult.stdout.toString()); + console.error(suppressResult.stdout.toString()); console.error('STDERR:'); - console.error(executableResult.stderr.toString()); + console.error(suppressResult.stderr.toString()); process.exit(1); } @@ -94,6 +97,29 @@ for (const runFolderPath of RUN_FOLDER_PATHS) { updateFilePaths.add(referenceSuppressionsJsonPath); FileSystem.writeFile(referenceSuppressionsJsonPath, newSuppressions); } + const eslintLoggingMessage = `-- Running eslint@${eslintVersion} in ${runFolderPath} --`; + console.log(eslintLoggingMessage); + + const eslintBinPath = path.resolve(__dirname, `node_modules/${eslintPackageName}/bin/eslint.js`); + + const executableResult = Executable.spawnSync(process.argv0, [eslintBinPath, 'src'], { + currentWorkingDirectory: folderPath, + environment: { + ...process.env, + PATH: shellPathWithEslint, + [ESLINT_PACKAGE_NAME_ENV_VAR_NAME]: eslintPackageName, + [ESLINT_BULK_ESLINTRC_FOLDER_PATH_ENV_VAR_NAME]: folderPath + } + }); + + if (executableResult.status !== 0) { + console.error('The eslint command failed.'); + console.error('STDOUT:'); + console.error(executableResult.stdout.toString()); + console.error('STDERR:'); + console.error(executableResult.stderr.toString()); + process.exit(1); + } FileSystem.deleteFile(suppressionsJsonPath); } diff --git a/build-tests/eslint-bulk-suppressions-test/build.js b/build-tests/eslint-bulk-suppressions-test/build.js index b51b460c3aa..dd6464ad888 100644 --- a/build-tests/eslint-bulk-suppressions-test/build.js +++ b/build-tests/eslint-bulk-suppressions-test/build.js @@ -5,7 +5,9 @@ const { FileSystem, Executable, Text, Import } = require('@rushstack/node-core-library'); const path = require('path'); const { - ESLINT_PACKAGE_NAME_ENV_VAR_NAME + ESLINT_PACKAGE_NAME_ENV_VAR_NAME, + ESLINT_BULK_ESLINTRC_FOLDER_PATH_ENV_VAR_NAME, + ESLINT_BULK_FORCE_REGENERATE_PATCH_ENV_VAR_NAME } = require('@rushstack/eslint-patch/lib/eslint-bulk-suppressions/constants'); const eslintBulkStartPath = Import.resolveModule({ @@ -65,7 +67,7 @@ for (const runFolderPath of RUN_FOLDER_PATHS) { ); const shellPathWithEslint = `${dependencyBinFolder}${path.delimiter}${process.env['PATH']}`; - const executableResult = Executable.spawnSync( + const suppressResult = Executable.spawnSync( process.argv0, [eslintBulkStartPath, 'suppress', '--all', 'src'], { @@ -73,17 +75,18 @@ for (const runFolderPath of RUN_FOLDER_PATHS) { environment: { ...process.env, PATH: shellPathWithEslint, - [ESLINT_PACKAGE_NAME_ENV_VAR_NAME]: eslintPackageName + [ESLINT_PACKAGE_NAME_ENV_VAR_NAME]: eslintPackageName, + [ESLINT_BULK_FORCE_REGENERATE_PATCH_ENV_VAR_NAME]: 'true' } } ); - if (executableResult.status !== 0) { + if (suppressResult.status !== 0) { console.error('The eslint-bulk-suppressions command failed.'); console.error('STDOUT:'); - console.error(executableResult.stdout.toString()); + console.error(suppressResult.stdout.toString()); console.error('STDERR:'); - console.error(executableResult.stderr.toString()); + console.error(suppressResult.stderr.toString()); process.exit(1); } @@ -94,6 +97,29 @@ for (const runFolderPath of RUN_FOLDER_PATHS) { updateFilePaths.add(referenceSuppressionsJsonPath); FileSystem.writeFile(referenceSuppressionsJsonPath, newSuppressions); } + const eslintLoggingMessage = `-- Running eslint@${eslintVersion} in ${runFolderPath} --`; + console.log(eslintLoggingMessage); + + const eslintBinPath = path.resolve(__dirname, `node_modules/${eslintPackageName}/bin/eslint.js`); + + const executableResult = Executable.spawnSync(process.argv0, [eslintBinPath, 'src'], { + currentWorkingDirectory: folderPath, + environment: { + ...process.env, + PATH: shellPathWithEslint, + [ESLINT_PACKAGE_NAME_ENV_VAR_NAME]: eslintPackageName, + [ESLINT_BULK_ESLINTRC_FOLDER_PATH_ENV_VAR_NAME]: folderPath + } + }); + + if (executableResult.status !== 0) { + console.error('The eslint command failed.'); + console.error('STDOUT:'); + console.error(executableResult.stdout.toString()); + console.error('STDERR:'); + console.error(executableResult.stderr.toString()); + process.exit(1); + } FileSystem.deleteFile(suppressionsJsonPath); } diff --git a/common/changes/@rushstack/eslint-patch/eslint-bulk-overhaul_2024-12-19-00-10.json b/common/changes/@rushstack/eslint-patch/eslint-bulk-overhaul_2024-12-19-00-10.json new file mode 100644 index 00000000000..342dea7dda3 --- /dev/null +++ b/common/changes/@rushstack/eslint-patch/eslint-bulk-overhaul_2024-12-19-00-10.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/eslint-patch", + "comment": "Ensure that lint problems suppressed by eslint-bulk-suppressions are available in the `getSuppressedMessages()` function on the linter. Defer evaluation of bulk suppressions until after inline suppressions.", + "type": "minor" + } + ], + "packageName": "@rushstack/eslint-patch" +} \ No newline at end of file diff --git a/common/changes/@rushstack/heft-lint-plugin/eslint-bulk-overhaul_2024-12-19-00-26.json b/common/changes/@rushstack/heft-lint-plugin/eslint-bulk-overhaul_2024-12-19-00-26.json new file mode 100644 index 00000000000..fe161e22498 --- /dev/null +++ b/common/changes/@rushstack/heft-lint-plugin/eslint-bulk-overhaul_2024-12-19-00-26.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-lint-plugin", + "comment": "Speed up heft-lint-plugin by telling eslint-bulk-suppressions exactly where to find the config file.", + "type": "patch" + } + ], + "packageName": "@rushstack/heft-lint-plugin" +} \ No newline at end of file diff --git a/common/changes/@rushstack/heft-lint-plugin/eslint-bulk-perf_2024-12-26-23-31.json b/common/changes/@rushstack/heft-lint-plugin/eslint-bulk-perf_2024-12-26-23-31.json new file mode 100644 index 00000000000..b5afc2f3cce --- /dev/null +++ b/common/changes/@rushstack/heft-lint-plugin/eslint-bulk-perf_2024-12-26-23-31.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-lint-plugin", + "comment": "Add a config option \"disableLintConfigSearch\" to turn off the linter's built-in scanning for config files relative to the linted files, and only use the config defined in the root of the current package.", + "type": "minor" + } + ], + "packageName": "@rushstack/heft-lint-plugin" +} \ No newline at end of file diff --git a/eslint/eslint-patch/src/eslint-bulk-suppressions/bulk-suppressions-file.ts b/eslint/eslint-patch/src/eslint-bulk-suppressions/bulk-suppressions-file.ts index 240d4ad541f..9d2edfeae2d 100644 --- a/eslint/eslint-patch/src/eslint-bulk-suppressions/bulk-suppressions-file.ts +++ b/eslint/eslint-patch/src/eslint-bulk-suppressions/bulk-suppressions-file.ts @@ -2,7 +2,7 @@ // See LICENSE in the project root for license information. import fs from 'fs'; -import { VSCODE_PID_ENV_VAR_NAME } from './constants'; +import { VSCODE_PID_ENV_VAR_NAME, SUPPRESSIONS_JSON_FILENAME } from './constants'; export interface ISuppression { file: string; @@ -23,7 +23,6 @@ export interface IBulkSuppressionsJson { const IS_RUNNING_IN_VSCODE: boolean = process.env[VSCODE_PID_ENV_VAR_NAME] !== undefined; const TEN_SECONDS_MS: number = 10 * 1000; -const SUPPRESSIONS_JSON_FILENAME: string = '.eslint-bulk-suppressions.json'; function throwIfAnythingOtherThanNotExistError(e: NodeJS.ErrnoException): void | never { if (e?.code !== 'ENOENT') { @@ -56,7 +55,8 @@ export function getSuppressionsConfigForEslintrcFolderPath( const suppressionsPath: string = `${eslintrcFolderPath}/${SUPPRESSIONS_JSON_FILENAME}`; let rawJsonFile: string | undefined; try { - rawJsonFile = fs.readFileSync(suppressionsPath).toString(); + // Decoding during read hits an optimized fast path in NodeJS >= 22. + rawJsonFile = fs.readFileSync(suppressionsPath, 'utf8'); } catch (e) { throwIfAnythingOtherThanNotExistError(e); } diff --git a/eslint/eslint-patch/src/eslint-bulk-suppressions/bulk-suppressions-patch.ts b/eslint/eslint-patch/src/eslint-bulk-suppressions/bulk-suppressions-patch.ts index 72978ceb69a..7da5bdf89aa 100644 --- a/eslint/eslint-patch/src/eslint-bulk-suppressions/bulk-suppressions-patch.ts +++ b/eslint/eslint-patch/src/eslint-bulk-suppressions/bulk-suppressions-patch.ts @@ -8,9 +8,8 @@ import * as Guards from './ast-guards'; import { eslintFolder } from '../_patch-base'; import { - ESLINT_BULK_ENABLE_ENV_VAR_NAME, - ESLINT_BULK_PRUNE_ENV_VAR_NAME, - ESLINT_BULK_SUPPRESS_ENV_VAR_NAME + ESLINT_BULK_SUPPRESS_ENV_VAR_NAME, + ESLINT_BULK_ESLINTRC_FOLDER_PATH_ENV_VAR_NAME } from './constants'; import { getSuppressionsConfigForEslintrcFolderPath, @@ -27,21 +26,49 @@ const ESLINTRC_FILENAMES: string[] = [ // Several other filenames are allowed, but this patch requires that it be loaded via a JS config file, // so we only need to check for the JS-based filenames ]; -const SUPPRESSION_SYMBOL: unique symbol = Symbol('suppression'); const ESLINT_BULK_SUPPRESS_ENV_VAR_VALUE: string | undefined = process.env[ESLINT_BULK_SUPPRESS_ENV_VAR_NAME]; const SUPPRESS_ALL_RULES: boolean = ESLINT_BULK_SUPPRESS_ENV_VAR_VALUE === '*'; const RULES_TO_SUPPRESS: Set | undefined = ESLINT_BULK_SUPPRESS_ENV_VAR_VALUE ? new Set(ESLINT_BULK_SUPPRESS_ENV_VAR_VALUE.split(',')) : undefined; +interface IBulkSuppression { + suppression: ISuppression; + serializedSuppression: string; +} + interface IProblem { - [SUPPRESSION_SYMBOL]?: { - config: IBulkSuppressionsConfig; - suppression: ISuppression; - serializedSuppression: string; + line: number; + column: number; + ruleId: string; + suppressions?: { + kind: string; + justification: string; + }[]; +} + +export type VerifyMethod = ( + textOrSourceCode: string, + config: unknown, + filename: string +) => IProblem[] | undefined; + +export interface ILinterClass { + prototype: { + verify: VerifyMethod; }; } +const astNodeForProblem: Map = new Map(); + +export function setAstNodeForProblem(problem: IProblem, node: TSESTree.Node): void { + astNodeForProblem.set(problem, node); +} + +interface ILinterInternalSlots { + lastSuppressedMessages: IProblem[] | undefined; +} + function getNodeName(node: TSESTree.Node): string | undefined { if (Guards.isClassDeclarationWithName(node)) { return node.id.name; @@ -91,6 +118,12 @@ function calculateScopeId(node: NodeWithParent | undefined): string { const eslintrcPathByFileOrFolderPath: Map = new Map(); function findEslintrcFolderPathForNormalizedFileAbsolutePath(normalizedFilePath: string): string { + // Heft, for example, suppresses nested eslintrc files, so it can pass this environment variable to suppress + // searching for the eslintrc file completely. + let eslintrcFolderPath: string | undefined = process.env[ESLINT_BULK_ESLINTRC_FOLDER_PATH_ENV_VAR_NAME]; + if (eslintrcFolderPath) { + return eslintrcFolderPath; + } const cachedFolderPathForFilePath: string | undefined = eslintrcPathByFileOrFolderPath.get(normalizedFilePath); if (cachedFolderPathForFilePath) { @@ -102,7 +135,6 @@ function findEslintrcFolderPathForNormalizedFileAbsolutePath(normalizedFilePath: ); const pathsToCache: string[] = [normalizedFilePath]; - let eslintrcFolderPath: string | undefined; findEslintrcFileLoop: for ( let currentFolder: string = normalizedFileFolderPath; currentFolder; // 'something'.substring(0, -1) is '' @@ -110,7 +142,9 @@ function findEslintrcFolderPathForNormalizedFileAbsolutePath(normalizedFilePath: ) { const cachedEslintrcFolderPath: string | undefined = eslintrcPathByFileOrFolderPath.get(currentFolder); if (cachedEslintrcFolderPath) { - return cachedEslintrcFolderPath; + // Break instead of returning to ensure that the cache entries for intervening folders get updated. + eslintrcFolderPath = currentFolder; + break; } pathsToCache.push(currentFolder); @@ -133,39 +167,46 @@ function findEslintrcFolderPathForNormalizedFileAbsolutePath(normalizedFilePath: } } -// One-line insert into the ruleContext report method to prematurely exit if the ESLint problem has been suppressed -export function shouldBulkSuppress(params: { - filename: string; - currentNode: TSESTree.Node; - ruleId: string; - problem: IProblem; -}): boolean { - // Use this ENV variable to turn off eslint-bulk-suppressions functionality, default behavior is on - if (process.env[ESLINT_BULK_ENABLE_ENV_VAR_NAME] === 'false') { - return false; +let rawGetLinterInternalSlots: ((linter: unknown) => ILinterInternalSlots) | undefined; + +export function getLinterInternalSlots(linter: unknown): ILinterInternalSlots { + if (!rawGetLinterInternalSlots) { + throw new Error('getLinterInternalSlots has not been set'); } - const { filename: fileAbsolutePath, currentNode, ruleId: rule, problem } = params; - const normalizedFileAbsolutePath: string = fileAbsolutePath.replace(/\\/g, '/'); - const eslintrcDirectory: string = - findEslintrcFolderPathForNormalizedFileAbsolutePath(normalizedFileAbsolutePath); - const fileRelativePath: string = normalizedFileAbsolutePath.substring(eslintrcDirectory.length + 1); + return rawGetLinterInternalSlots(linter); +} + +export function getBulkSuppression(params: { + serializedSuppressions: Set; + fileRelativePath: string; + problem: IProblem; +}): IBulkSuppression | undefined { + const { fileRelativePath, serializedSuppressions, problem } = params; + const { ruleId: rule } = problem; + + const currentNode: TSESTree.Node | undefined = astNodeForProblem.get(problem); + const scopeId: string = calculateScopeId(currentNode); const suppression: ISuppression = { file: fileRelativePath, scopeId, rule }; - const config: IBulkSuppressionsConfig = getSuppressionsConfigForEslintrcFolderPath(eslintrcDirectory); const serializedSuppression: string = serializeSuppression(suppression); - const currentNodeIsSuppressed: boolean = config.serializedSuppressions.has(serializedSuppression); + const currentNodeIsSuppressed: boolean = serializedSuppressions.has(serializedSuppression); if (currentNodeIsSuppressed || SUPPRESS_ALL_RULES || RULES_TO_SUPPRESS?.has(suppression.rule)) { - problem[SUPPRESSION_SYMBOL] = { + // The suppressions object should already be empty, otherwise we shouldn't see this problem + problem.suppressions = [ + { + kind: 'bulk', + justification: serializedSuppression + } + ]; + + return { suppression, - serializedSuppression, - config + serializedSuppression }; } - - return process.env[ESLINT_BULK_PRUNE_ENV_VAR_NAME] !== '1' && currentNodeIsSuppressed; } export function prune(): void { @@ -187,15 +228,11 @@ export function prune(): void { } } +/** + * @deprecated Use "prune" instead. + */ export function write(): void { - for (const [ - eslintrcFolderPath, - suppressionsConfig - ] of getAllBulkSuppressionsConfigsByEslintrcFolderPath()) { - if (suppressionsConfig) { - writeSuppressionsJsonToFile(eslintrcFolderPath, suppressionsConfig); - } - } + return prune(); } // utility function for linter-patch.js to make require statements that use relative paths in linter.js work in linter-patch.js @@ -209,56 +246,97 @@ export function requireFromPathToLinterJS(importPath: string): import('eslint'). return require(moduleAbsolutePath); } -export function patchClass(originalClass: new () => T, patchedClass: new () => U): void { - // Get all the property names of the patched class prototype - const patchedProperties: string[] = Object.getOwnPropertyNames(patchedClass.prototype); - - // Loop through all the properties - for (const prop of patchedProperties) { - // Override the property in the original class - originalClass.prototype[prop] = patchedClass.prototype[prop]; - } +/** + * Patches ESLint's Linter class to support bulk suppressions + * @param originalClass - The original Linter class from ESLint + * @param patchedClass - The patched Linter class from the generated file + * @param originalGetLinterInternalSlots - The original getLinterInternalSlots function from ESLint + */ +export function patchLinter( + originalClass: ILinterClass, + patchedClass: ILinterClass, + originalGetLinterInternalSlots: typeof getLinterInternalSlots +): void { + // Ensure we use the correct internal slots map + rawGetLinterInternalSlots = originalGetLinterInternalSlots; - // Handle getters and setters + // Transfer all properties for (const [prop, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(patchedClass.prototype))) { - if (descriptor.get || descriptor.set) { - Object.defineProperty(originalClass.prototype, prop, descriptor); - } + Object.defineProperty(originalClass.prototype, prop, descriptor); } -} -/** - * This returns a wrapped version of the "verify" function from ESLint's Linter class - * that postprocesses rule violations that weren't suppressed by comments. This postprocessing - * records suppressions that weren't otherwise suppressed by comments to be used - * by the "suppress" and "prune" commands. - */ -export function extendVerifyFunction( - originalFn: (this: unknown, ...args: unknown[]) => IProblem[] | undefined -): (this: unknown, ...args: unknown[]) => IProblem[] | undefined { - return function (this: unknown, ...args: unknown[]): IProblem[] | undefined { - const problems: IProblem[] | undefined = originalFn.apply(this, args); - if (problems) { + const originalVerify: (...args: unknown[]) => IProblem[] | undefined = originalClass.prototype.verify as ( + ...args: unknown[] + ) => IProblem[] | undefined; + originalClass.prototype.verify = verify; + + function verify(this: unknown, ...args: unknown[]): IProblem[] | undefined { + try { + const problems: IProblem[] | undefined = originalVerify.apply(this, args); + if (!problems) { + return problems; + } + + const internalSlots: ILinterInternalSlots = getLinterInternalSlots(this); + + if (args.length < 3) { + throw new Error('Expected at least 3 arguments to Linter.prototype.verify'); + } + + const fileNameOrOptions: string | { filename: string } = args[2] as string | { filename: string }; + const filename: string = + typeof fileNameOrOptions === 'string' ? fileNameOrOptions : fileNameOrOptions.filename; + + let { lastSuppressedMessages } = internalSlots; + + const normalizedFileAbsolutePath: string = filename.replace(/\\/g, '/'); + const eslintrcDirectory: string = + findEslintrcFolderPathForNormalizedFileAbsolutePath(normalizedFileAbsolutePath); + const fileRelativePath: string = normalizedFileAbsolutePath.substring(eslintrcDirectory.length + 1); + const config: IBulkSuppressionsConfig = getSuppressionsConfigForEslintrcFolderPath(eslintrcDirectory); + const { + newSerializedSuppressions, + serializedSuppressions, + jsonObject: { suppressions }, + newJsonObject: { suppressions: newSuppressions } + } = config; + + const filteredProblems: IProblem[] = []; + for (const problem of problems) { - if (problem[SUPPRESSION_SYMBOL]) { - const { - serializedSuppression, - suppression, - config: { - newSerializedSuppressions, - jsonObject: { suppressions }, - newJsonObject: { suppressions: newSuppressions } - } - } = problem[SUPPRESSION_SYMBOL]; - if (!newSerializedSuppressions.has(serializedSuppression)) { - newSerializedSuppressions.add(serializedSuppression); - newSuppressions.push(suppression); + const bulkSuppression: IBulkSuppression | undefined = getBulkSuppression({ + fileRelativePath, + serializedSuppressions, + problem + }); + + if (!bulkSuppression) { + filteredProblems.push(problem); + continue; + } + + const { serializedSuppression, suppression } = bulkSuppression; + + if (!newSerializedSuppressions.has(serializedSuppression)) { + newSerializedSuppressions.add(serializedSuppression); + newSuppressions.push(suppression); + + if (!serializedSuppressions.has(serializedSuppression)) { suppressions.push(suppression); } + + if (!lastSuppressedMessages) { + lastSuppressedMessages = []; + internalSlots.lastSuppressedMessages = lastSuppressedMessages; + } + + lastSuppressedMessages.push(problem); } } - } - return problems; - }; + return filteredProblems; + } finally { + astNodeForProblem.clear(); + } + } } diff --git a/eslint/eslint-patch/src/eslint-bulk-suppressions/constants.ts b/eslint/eslint-patch/src/eslint-bulk-suppressions/constants.ts index 0d505df08ab..9f20e3d826d 100644 --- a/eslint/eslint-patch/src/eslint-bulk-suppressions/constants.ts +++ b/eslint/eslint-patch/src/eslint-bulk-suppressions/constants.ts @@ -7,6 +7,8 @@ export const ESLINT_BULK_SUPPRESS_ENV_VAR_NAME: 'RUSHSTACK_ESLINT_BULK_SUPPRESS' 'RUSHSTACK_ESLINT_BULK_SUPPRESS'; export const ESLINT_BULK_ENABLE_ENV_VAR_NAME: 'ESLINT_BULK_ENABLE' = 'ESLINT_BULK_ENABLE'; export const ESLINT_BULK_PRUNE_ENV_VAR_NAME: 'ESLINT_BULK_PRUNE' = 'ESLINT_BULK_PRUNE'; +export const ESLINT_BULK_ESLINTRC_FOLDER_PATH_ENV_VAR_NAME: 'ESLINT_BULK_ESLINTRC_FOLDER_PATH' = + 'ESLINT_BULK_ESLINTRC_FOLDER_PATH'; export const ESLINT_BULK_DETECT_ENV_VAR_NAME: '_RUSHSTACK_ESLINT_BULK_DETECT' = '_RUSHSTACK_ESLINT_BULK_DETECT'; export const ESLINT_BULK_FORCE_REGENERATE_PATCH_ENV_VAR_NAME: 'RUSHSTACK_ESLINT_BULK_FORCE_REGENERATE_PATCH' = @@ -18,3 +20,5 @@ export const ESLINT_PACKAGE_NAME_ENV_VAR_NAME: '_RUSHSTACK_ESLINT_PACKAGE_NAME' export const BULK_SUPPRESSIONS_CLI_ESLINT_PACKAGE_NAME: string = process.env[ESLINT_PACKAGE_NAME_ENV_VAR_NAME] ?? 'eslint'; + +export const SUPPRESSIONS_JSON_FILENAME: '.eslint-bulk-suppressions.json' = '.eslint-bulk-suppressions.json'; diff --git a/eslint/eslint-patch/src/eslint-bulk-suppressions/generate-patched-file.ts b/eslint/eslint-patch/src/eslint-bulk-suppressions/generate-patched-file.ts index fdb97d28afa..c0a63f2de46 100644 --- a/eslint/eslint-patch/src/eslint-bulk-suppressions/generate-patched-file.ts +++ b/eslint/eslint-patch/src/eslint-bulk-suppressions/generate-patched-file.ts @@ -50,63 +50,27 @@ export function generatePatchedLinterJsFileIfDoesNotExist( throw new Error('Unexpected end of input while looking for ' + JSON.stringify(marker)); } - function scanUntilNewline(): string { - let output: string = ''; - - while (inputIndex < inputFile.length) { - const char: string = inputFile[inputIndex++]; - output += char; - if (char === '\n') { - return output; + function scanUntilToken(token: string, required: boolean): string { + const tokenIndex: number = inputFile.indexOf(token, inputIndex); + if (tokenIndex < 0) { + if (required) { + throw new Error('Unexpected end of input while looking for new line'); + } else { + return scanUntilEnd(); } } - throw new Error('Unexpected end of input while looking for new line'); - } - - function scanUntilEnd(): string { - const output: string = inputFile.substring(inputIndex); - inputIndex = inputFile.length; - return output; + inputIndex = tokenIndex + token.length; + return inputFile.slice(inputIndex, tokenIndex); } - /** - * Returns index of next public method - * @param fromIndex - index of inputFile to search if public method still exists - * @returns -1 if public method does not exist or index of next public method - */ - function getIndexOfNextPublicMethod(fromIndex: number): number { - const rest: string = inputFile.substring(fromIndex); - - const endOfClassIndex: number = rest.indexOf('\n}'); - - const markerForStartOfClassMethod: string = '\n */\n '; - - const startOfClassMethodIndex: number = rest.indexOf(markerForStartOfClassMethod); - - if (startOfClassMethodIndex === -1 || startOfClassMethodIndex > endOfClassIndex) { - return -1; - } - - const afterMarkerIndex: number = - rest.indexOf(markerForStartOfClassMethod) + markerForStartOfClassMethod.length; - - const isPublicMethod: boolean = - rest[afterMarkerIndex] !== '_' && - rest[afterMarkerIndex] !== '#' && - !rest.substring(afterMarkerIndex, rest.indexOf('\n', afterMarkerIndex)).includes('static') && - !rest.substring(afterMarkerIndex, rest.indexOf('\n', afterMarkerIndex)).includes('constructor'); - - if (isPublicMethod) { - return fromIndex + afterMarkerIndex; - } - - return getIndexOfNextPublicMethod(fromIndex + afterMarkerIndex); + function scanUntilNewline(): string { + return scanUntilToken('\n', true); } - function scanUntilIndex(indexToScanTo: number): string { - const output: string = inputFile.substring(inputIndex, indexToScanTo); - inputIndex = indexToScanTo; + function scanUntilEnd(): string { + const output: string = inputFile.slice(inputIndex); + inputIndex = inputFile.length; return output; } @@ -193,7 +157,7 @@ const requireFromPathToLinterJS = bulkSuppressionsPatch.requireFromPathToLinterJ // } // const problem = reportTranslator(...args); // // --- BEGIN MONKEY PATCH --- - // if (bulkSuppressionsPatch.shouldBulkSuppress({ filename, currentNode, ruleId })) return; + // bulkSuppressionsPatch.setAstNodeForProblem(problem, currentNode); // // --- END MONKEY PATCH --- // // if (problem.fix && !(rule.meta && rule.meta.fixable)) { @@ -203,52 +167,27 @@ const requireFromPathToLinterJS = bulkSuppressionsPatch.requireFromPathToLinterJ outputFile += scanUntilMarker('const problem = reportTranslator(...args);'); outputFile += ` // --- BEGIN MONKEY PATCH --- - if (bulkSuppressionsPatch.shouldBulkSuppress({ filename, currentNode, ruleId, problem })) return; + bulkSuppressionsPatch.setAstNodeForProblem(problem, currentNode); // --- END MONKEY PATCH --- `; outputFile += scanUntilMarker('nodeQueue.forEach(traversalInfo => {'); outputFile += scanUntilMarker('});'); outputFile += scanUntilNewline(); - outputFile += scanUntilMarker('class Linter {'); - outputFile += scanUntilNewline(); - outputFile += ` - // --- BEGIN MONKEY PATCH --- - /** - * We intercept ESLint execution at the .eslintrc.js file, but unfortunately the Linter class is - * initialized before the .eslintrc.js file is executed. This means the internalSlotsMap that all - * the patched methods refer to is not initialized. This method checks if the internalSlotsMap is - * initialized, and if not, initializes it. - */ - _conditionallyReinitialize({ cwd, configType } = {}) { - if (internalSlotsMap.get(this) === undefined) { - internalSlotsMap.set(this, { - cwd: normalizeCwd(cwd), - lastConfigArray: null, - lastSourceCode: null, - lastSuppressedMessages: [], - configType, // TODO: Remove after flat config conversion - parserMap: new Map([['espree', espree]]), - ruleMap: new Rules() - }); - - this.version = pkg.version; - } + outputFile += scanUntilMarker('const internalSlotsMap'); + outputFile += ` = /* --- BEGIN MONKEY PATCH --- */{ + get(key) { + return bulkSuppressionsPatch.getLinterInternalSlots(key); + }, + set(key) { + // Do nothing; constructor is unused } - // --- END MONKEY PATCH --- -`; - - let indexOfNextPublicMethod: number = getIndexOfNextPublicMethod(inputIndex); - while (indexOfNextPublicMethod !== -1) { - outputFile += scanUntilIndex(indexOfNextPublicMethod); - outputFile += scanUntilNewline(); - outputFile += ` // --- BEGIN MONKEY PATCH --- - this._conditionallyReinitialize(); - // --- END MONKEY PATCH --- -`; - indexOfNextPublicMethod = getIndexOfNextPublicMethod(inputIndex); + } /* --- END MONKEY PATCH --- */;`; + const newlineIndex: number = inputFile.indexOf('\n', inputIndex); + if (newlineIndex < 0) { + throw new Error('Unexpected end of input while looking for new line'); } - + inputIndex = newlineIndex; outputFile += scanUntilEnd(); fs.writeFileSync(outputFilePath, outputFile); diff --git a/eslint/eslint-patch/src/eslint-bulk-suppressions/index.ts b/eslint/eslint-patch/src/eslint-bulk-suppressions/index.ts index 2297af04063..ab21d401faa 100644 --- a/eslint/eslint-patch/src/eslint-bulk-suppressions/index.ts +++ b/eslint/eslint-patch/src/eslint-bulk-suppressions/index.ts @@ -3,7 +3,7 @@ import { eslintFolder } from '../_patch-base'; import { findAndConsoleLogPatchPathCli, getPathToLinterJS, ensurePathToGeneratedPatch } from './path-utils'; -import { patchClass, extendVerifyFunction } from './bulk-suppressions-patch'; +import { patchLinter } from './bulk-suppressions-patch'; import { generatePatchedLinterJsFileIfDoesNotExist } from './generate-patched-file'; import { ESLINT_BULK_DETECT_ENV_VAR_NAME, ESLINT_BULK_PATCH_PATH_ENV_VAR_NAME } from './constants'; @@ -27,9 +27,9 @@ process.env[ESLINT_BULK_PATCH_PATH_ENV_VAR_NAME] = require.resolve('./bulk-suppr const pathToGeneratedPatch: string = ensurePathToGeneratedPatch(); generatePatchedLinterJsFileIfDoesNotExist(pathToLinterJS, pathToGeneratedPatch); + const { Linter: LinterPatch } = require(pathToGeneratedPatch); -LinterPatch.prototype.verify = extendVerifyFunction(LinterPatch.prototype.verify); -const { Linter } = require(pathToLinterJS); +const { Linter, getLinterInternalSlots } = require(pathToLinterJS); -patchClass(Linter, LinterPatch); +patchLinter(Linter, LinterPatch, getLinterInternalSlots); diff --git a/heft-plugins/heft-lint-plugin/src/Eslint.ts b/heft-plugins/heft-lint-plugin/src/Eslint.ts index b5027f2b171..fa7c13dfe8d 100644 --- a/heft-plugins/heft-lint-plugin/src/Eslint.ts +++ b/heft-plugins/heft-lint-plugin/src/Eslint.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. import * as crypto from 'crypto'; +import * as path from 'path'; import * as semver from 'semver'; import type * as TTypescript from 'typescript'; import type * as TEslint from 'eslint'; @@ -66,6 +67,7 @@ export class Eslint extends LinterBase { const { buildFolderPath, + disableLintConfigSearch, eslintPackage, linterConfigFilePath, tsProgram, @@ -100,12 +102,15 @@ export class Eslint extends LinterBase { }; } + process.env.ESLINT_BULK_ESLINTRC_FOLDER_PATH = path.dirname(linterConfigFilePath); this._linter = new eslintPackage.ESLint({ cwd: buildFolderPath, overrideConfigFile: linterConfigFilePath, // Override config takes precedence over overrideConfigFile overrideConfig, - fix: fixFn + fix: fixFn, + // If requested, disable the scan for .eslintrc files relative to linted files + useEslintrc: !disableLintConfigSearch }); this._eslintTimings = eslintTimings; } diff --git a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts index aabfe0dc561..b3a3da475c7 100644 --- a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts +++ b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts @@ -30,6 +30,7 @@ const ESLINTRC_CJS_FILENAME: string = '.eslintrc.cjs'; interface ILintPluginOptions { alwaysFix?: boolean; + disableLintConfigSearch?: boolean; sarifLogPath?: string; } @@ -40,6 +41,7 @@ interface ILintOptions { fix?: boolean; sarifLogPath?: string; changedFiles?: ReadonlySet; + disableLintConfigSearch?: boolean; } export default class LintPlugin implements IHeftTaskPlugin { @@ -74,6 +76,7 @@ export default class LintPlugin implements IHeftTaskPlugin { const relativeSarifLogPath: string | undefined = pluginOptions?.sarifLogPath; const sarifLogPath: string | undefined = relativeSarifLogPath && path.resolve(heftConfiguration.buildFolderPath, relativeSarifLogPath); + const disableLintConfigSearch: boolean = pluginOptions?.disableLintConfigSearch || false; // Use the changed files hook to kick off linting asynchronously taskSession.requestAccessToPluginByName( @@ -90,7 +93,8 @@ export default class LintPlugin implements IHeftTaskPlugin { fix, sarifLogPath, tsProgram: changedFilesHookOptions.program as IExtendedProgram, - changedFiles: changedFilesHookOptions.changedFiles as ReadonlySet + changedFiles: changedFilesHookOptions.changedFiles as ReadonlySet, + disableLintConfigSearch }); lintingPromise.catch(() => { // Suppress unhandled promise rejection error @@ -153,7 +157,15 @@ export default class LintPlugin implements IHeftTaskPlugin { } private async _lintAsync(options: ILintOptions): Promise { - const { taskSession, heftConfiguration, tsProgram, changedFiles, fix, sarifLogPath } = options; + const { + taskSession, + heftConfiguration, + tsProgram, + changedFiles, + fix, + sarifLogPath, + disableLintConfigSearch + } = options; // Ensure that we have initialized. This promise is cached, so calling init // multiple times will only init once. @@ -169,7 +181,8 @@ export default class LintPlugin implements IHeftTaskPlugin { linterToolPath: this._eslintToolPath, linterConfigFilePath: this._eslintConfigFilePath, buildFolderPath: heftConfiguration.buildFolderPath, - buildMetadataFolderPath: taskSession.tempFolderPath + buildMetadataFolderPath: taskSession.tempFolderPath, + disableLintConfigSearch }); linters.push(eslintLinter); } diff --git a/heft-plugins/heft-lint-plugin/src/LinterBase.ts b/heft-plugins/heft-lint-plugin/src/LinterBase.ts index a198e4fab23..0875629bab5 100644 --- a/heft-plugins/heft-lint-plugin/src/LinterBase.ts +++ b/heft-plugins/heft-lint-plugin/src/LinterBase.ts @@ -17,6 +17,7 @@ export interface ILinterBaseOptions { * The path where the linter state will be written to. */ buildMetadataFolderPath: string; + disableLintConfigSearch?: boolean; linterToolPath: string; linterConfigFilePath: string; tsProgram: IExtendedProgram; diff --git a/heft-plugins/heft-lint-plugin/src/schemas/heft-lint-plugin.schema.json b/heft-plugins/heft-lint-plugin/src/schemas/heft-lint-plugin.schema.json index 072d2c70df6..dca2bc73f2c 100644 --- a/heft-plugins/heft-lint-plugin/src/schemas/heft-lint-plugin.schema.json +++ b/heft-plugins/heft-lint-plugin/src/schemas/heft-lint-plugin.schema.json @@ -13,6 +13,12 @@ "type": "boolean" }, + "disableLintConfigSearch": { + "title": "Disable ESLint Config Search", + "description": "If set to true and using ESLint, ESLint will not scan for configuration files in parent directories of the linted files.", + "type": "boolean" + }, + "sarifLogPath": { "title": "SARIF Log Path", "description": "If specified and using ESLint, a log describing the lint configuration and all messages (suppressed or not) will be emitted in the Static Analysis Results Interchange Format (https://sarifweb.azurewebsites.net/) at the provided path, relative to the project root.", diff --git a/rigs/local-node-rig/profiles/default/config/heft.json b/rigs/local-node-rig/profiles/default/config/heft.json index fbb9cbb25ee..646f7f4c7f4 100644 --- a/rigs/local-node-rig/profiles/default/config/heft.json +++ b/rigs/local-node-rig/profiles/default/config/heft.json @@ -1,5 +1,19 @@ { - "$schema": "https://developer.microsoft.com/json-schemas/heft/heft.schema.json", + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json", - "extends": "@rushstack/heft-node-rig/profiles/default/config/heft.json" + "extends": "@rushstack/heft-node-rig/profiles/default/config/heft.json", + + "phasesByName": { + "build": { + "tasksByName": { + "lint": { + "taskPlugin": { + "options": { + "disableLintConfigSearch": true + } + } + } + } + } + } } diff --git a/rigs/local-web-rig/profiles/app/config/heft.json b/rigs/local-web-rig/profiles/app/config/heft.json index 24180df49a8..3042d7eb635 100644 --- a/rigs/local-web-rig/profiles/app/config/heft.json +++ b/rigs/local-web-rig/profiles/app/config/heft.json @@ -1,5 +1,19 @@ { - "$schema": "https://developer.microsoft.com/json-schemas/heft/heft.schema.json", + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json", - "extends": "@rushstack/heft-web-rig/profiles/app/config/heft.json" + "extends": "@rushstack/heft-web-rig/profiles/app/config/heft.json", + + "phasesByName": { + "build": { + "tasksByName": { + "lint": { + "taskPlugin": { + "options": { + "disableLintConfigSearch": true + } + } + } + } + } + } } diff --git a/rigs/local-web-rig/profiles/library/config/heft.json b/rigs/local-web-rig/profiles/library/config/heft.json index 276675e3b70..e2576469d43 100644 --- a/rigs/local-web-rig/profiles/library/config/heft.json +++ b/rigs/local-web-rig/profiles/library/config/heft.json @@ -1,5 +1,19 @@ { - "$schema": "https://developer.microsoft.com/json-schemas/heft/heft.schema.json", + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json", - "extends": "@rushstack/heft-web-rig/profiles/library/config/heft.json" + "extends": "@rushstack/heft-web-rig/profiles/library/config/heft.json", + + "phasesByName": { + "build": { + "tasksByName": { + "lint": { + "taskPlugin": { + "options": { + "disableLintConfigSearch": true + } + } + } + } + } + } } diff --git a/rush.json b/rush.json index 3c42d3cf058..9922ba0a718 100644 --- a/rush.json +++ b/rush.json @@ -544,6 +544,7 @@ "projectFolder": "build-tests/eslint-7-7-test", "reviewCategory": "tests", "shouldPublish": false, + "tags": ["eslint-tests"], "cyclicDependencyProjects": ["@rushstack/eslint-config"] }, { @@ -551,6 +552,7 @@ "projectFolder": "build-tests/eslint-7-11-test", "reviewCategory": "tests", "shouldPublish": false, + "tags": ["eslint-tests"], "cyclicDependencyProjects": ["@rushstack/eslint-config"] }, { @@ -558,25 +560,29 @@ "projectFolder": "build-tests/eslint-7-test", "reviewCategory": "tests", "shouldPublish": false, + "tags": ["eslint-tests"], "cyclicDependencyProjects": ["@rushstack/eslint-config"] }, { "packageName": "eslint-8-test", "projectFolder": "build-tests/eslint-8-test", "reviewCategory": "tests", - "shouldPublish": false + "shouldPublish": false, + "tags": ["eslint-tests"] }, { "packageName": "eslint-bulk-suppressions-test", "projectFolder": "build-tests/eslint-bulk-suppressions-test", "reviewCategory": "tests", - "shouldPublish": false + "shouldPublish": false, + "tags": ["eslint-tests"] }, { "packageName": "eslint-bulk-suppressions-test-legacy", "projectFolder": "build-tests/eslint-bulk-suppressions-test-legacy", "reviewCategory": "tests", "shouldPublish": false, + "tags": ["eslint-tests"], "cyclicDependencyProjects": ["@rushstack/eslint-config"] }, {