From 991f30d25af18266d4010f489f2207a3b5acc1a1 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 2 Sep 2026 02:08:04 -0400 Subject: [PATCH] Lint files selected by ESLint flat config Use ESLint's native flat-config enumeration to find files outside the TypeScript program, then lint them through the existing cache and reporting pipeline. Exclude TypeScript emit folders and cover the behavior with the ESLint 9 SARIF fixture. --- .../.eslint-bulk-suppressions.json | 5 + build-tests/eslint-9-test/eslint.config.js | 9 ++ .../src/__snapshots__/sarif.test.ts.snap | 48 ++++++++ .../eslint-9-test/src/non-program.custom | 1 + ...flat-config-files_2026-09-01-12-00-00.json | 9 ++ heft-plugins/heft-lint-plugin/src/Eslint.ts | 102 +++++++++++++++-- .../heft-lint-plugin/src/LintPlugin.ts | 104 ++++++++++++++---- .../heft-lint-plugin/src/LinterBase.ts | 45 ++++++-- 8 files changed, 284 insertions(+), 39 deletions(-) create mode 100644 build-tests/eslint-9-test/src/non-program.custom create mode 100644 common/changes/@rushstack/heft-lint-plugin/eslint-flat-config-files_2026-09-01-12-00-00.json diff --git a/build-tests/eslint-9-test/.eslint-bulk-suppressions.json b/build-tests/eslint-9-test/.eslint-bulk-suppressions.json index 961e6033858..467159daa95 100644 --- a/build-tests/eslint-9-test/.eslint-bulk-suppressions.json +++ b/build-tests/eslint-9-test/.eslint-bulk-suppressions.json @@ -4,6 +4,11 @@ "file": "src/index.ts", "scopeId": ".", "rule": "@typescript-eslint/naming-convention" + }, + { + "file": "src/non-program.custom", + "scopeId": ".", + "rule": "no-undef" } ] } diff --git a/build-tests/eslint-9-test/eslint.config.js b/build-tests/eslint-9-test/eslint.config.js index 75eb0c727fc..ca63423a8d6 100644 --- a/build-tests/eslint-9-test/eslint.config.js +++ b/build-tests/eslint-9-test/eslint.config.js @@ -7,6 +7,9 @@ const nodeTrustedToolProfile = require('local-node-rig/profiles/default/includes const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); module.exports = [ + { + ignores: ['coverage/**'] + }, ...nodeTrustedToolProfile, ...friendlyLocalsMixin, { @@ -25,5 +28,11 @@ module.exports = [ tsconfigRootDir: __dirname } } + }, + { + files: ['**/*.custom'], + rules: { + 'no-undef': 'warn' + } } ]; diff --git a/build-tests/eslint-9-test/src/__snapshots__/sarif.test.ts.snap b/build-tests/eslint-9-test/src/__snapshots__/sarif.test.ts.snap index 0ddfa4d6a6f..ff4ac1f6fad 100644 --- a/build-tests/eslint-9-test/src/__snapshots__/sarif.test.ts.snap +++ b/build-tests/eslint-9-test/src/__snapshots__/sarif.test.ts.snap @@ -16,6 +16,16 @@ Object { "uri": "src/sarif.test.ts", }, }, + Object { + "location": Object { + "uri": "eslint.config.js", + }, + }, + Object { + "location": Object { + "uri": "src/non-program.custom", + }, + }, ], "results": Array [ Object { @@ -78,6 +88,36 @@ Object { }, ], }, + Object { + "level": "warning", + "locations": Array [ + Object { + "physicalLocation": Object { + "artifactLocation": Object { + "index": 3, + "uri": "src/non-program.custom", + }, + "region": Object { + "endColumn": 14, + "endLine": 1, + "startColumn": 1, + "startLine": 1, + }, + }, + }, + ], + "message": Object { + "text": "'missingGlobal' is not defined.", + }, + "ruleId": "no-undef", + "ruleIndex": 2, + "suppressions": Array [ + Object { + "justification": "", + "kind": "external", + }, + ], + }, ], "tool": Object { "driver": Object { @@ -100,6 +140,14 @@ Object { "text": "Enforce naming conventions for everything across a codebase", }, }, + Object { + "helpUri": "https://eslint.org/docs/latest/rules/no-undef", + "id": "no-undef", + "properties": Object {}, + "shortDescription": Object { + "text": "Disallow the use of undeclared variables unless mentioned in \`/*global */\` comments", + }, + }, ], "version": "9.37.0", }, diff --git a/build-tests/eslint-9-test/src/non-program.custom b/build-tests/eslint-9-test/src/non-program.custom new file mode 100644 index 00000000000..7b7f2da4753 --- /dev/null +++ b/build-tests/eslint-9-test/src/non-program.custom @@ -0,0 +1 @@ +missingGlobal; \ No newline at end of file diff --git a/common/changes/@rushstack/heft-lint-plugin/eslint-flat-config-files_2026-09-01-12-00-00.json b/common/changes/@rushstack/heft-lint-plugin/eslint-flat-config-files_2026-09-01-12-00-00.json new file mode 100644 index 00000000000..812cfed1c1f --- /dev/null +++ b/common/changes/@rushstack/heft-lint-plugin/eslint-flat-config-files_2026-09-01-12-00-00.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-lint-plugin", + "comment": "Lint files selected by ESLint flat config even when they are not part of the TypeScript program.", + "type": "patch" + } + ] +} diff --git a/heft-plugins/heft-lint-plugin/src/Eslint.ts b/heft-plugins/heft-lint-plugin/src/Eslint.ts index aea83cbee8d..7866ea9d39b 100644 --- a/heft-plugins/heft-lint-plugin/src/Eslint.ts +++ b/heft-plugins/heft-lint-plugin/src/Eslint.ts @@ -11,10 +11,10 @@ import type * as TEslintLegacy from 'eslint-8'; import * as semver from 'semver'; import stableStringify from 'json-stable-stringify-without-jsonify'; -import { FileError, FileSystem } from '@rushstack/node-core-library'; +import { FileError, FileSystem, Path } from '@rushstack/node-core-library'; import type { HeftConfiguration } from '@rushstack/heft'; -import { LinterBase, type ILinterBaseOptions } from './LinterBase'; +import { LinterBase, type IAdditionalLintFile, type ILinterBaseOptions } from './LinterBase'; import type { IExtendedSourceFile } from './internalTypings/TypeScriptInternals'; import { name as pluginName, version as pluginVersion } from '../package.json'; @@ -82,10 +82,14 @@ const ESLINT_LEGACY_CONFIG_FILENAMES: Set = new Set([ LEGACY_ESLINTRC_CJS_FILENAME ]); -export class Eslint extends LinterBase { +export class Eslint extends LinterBase< + TEslint.ESLint.LintResult | TEslintLegacy.ESLint.LintResult, + IAdditionalLintFile +> { private readonly _eslintPackage: typeof TEslint | typeof TEslintLegacy; private readonly _eslintPackageVersion: semver.SemVer; private readonly _linter: TEslint.ESLint | TEslintLegacy.ESLint; + private readonly _additionalFilesLinter: TEslint.ESLint | undefined; private readonly _eslintTimings: Map = new Map(); private readonly _currentFixMessages: (TEslint.Linter.LintMessage | TEslintLegacy.Linter.LintMessage)[] = []; @@ -95,6 +99,8 @@ export class Eslint extends LinterBase = new Map(); private readonly _sarifLogPath: string | undefined; private readonly _configHashMap: WeakMap = new WeakMap(); + private readonly _fileEnumerator: TEslint.ESLint | undefined; + private readonly _typeScriptFilenames: ReadonlySet; protected constructor(options: IEslintOptions) { super('eslint', options); @@ -106,7 +112,8 @@ export class Eslint extends LinterBase path.resolve(filePath)) + ); + if (this._eslintPackageVersion.major >= 9) { + const typeScriptIgnorePatterns: string[] = Array.from(this._typeScriptFilenames, (filePath: string) => + Path.convertToSlashes(path.relative(buildFolderPath, filePath)) + ).filter((relativePath: string) => relativePath !== '..' && !relativePath.startsWith('../')); + const flatEslintPackage: typeof TEslint = eslintPackage as typeof TEslint; + this._additionalFilesLinter = new flatEslintPackage.ESLint({ + cwd: buildFolderPath, + overrideConfigFile: linterConfigFilePath, + fix: fixFn + }); + this._fileEnumerator = new flatEslintPackage.ESLint({ + cwd: buildFolderPath, + errorOnUnmatchedPattern: false, + overrideConfigFile: linterConfigFilePath, + overrideConfig: { + name: `${pluginName}/ignore-typescript-program-files`, + ignores: [...typeScriptIgnorePatterns, ...(additionalFileIgnorePatterns || [])] + }, + ruleFilter: () => false + }); + } + this._eslintTimings = eslintTimings; } @@ -250,12 +283,44 @@ export class Eslint extends LinterBase> { + if (!this._fileEnumerator) { + return new Set(); + } + + const lintResults: TEslint.ESLint.LintResult[] = await this._fileEnumerator.lintFiles(['.']); + lintResults.sort((left: TEslint.ESLint.LintResult, right: TEslint.ESLint.LintResult) => + left.filePath.localeCompare(right.filePath) + ); + + const additionalLintFiles: IAdditionalLintFile[] = await Promise.all( + lintResults + .filter( + (lintResult: TEslint.ESLint.LintResult) => + !this._typeScriptFilenames.has(path.resolve(lintResult.filePath)) + ) + .map(async (lintResult: TEslint.ESLint.LintResult): Promise => { + return { + kind: 'additional', + fileName: lintResult.filePath, + text: await FileSystem.readFileAsync(lintResult.filePath), + version: '' + }; + }) + ); + + return new Set(additionalLintFiles); + } + protected override async getCacheVersionAsync(): Promise { return `${this._eslintPackageVersion.version}_${process.version}`; } - protected override async getSourceFileHashAsync(sourceFile: IExtendedSourceFile): Promise { - const sourceFileEslintConfiguration: TEslint.Linter.Config = await this._linter.calculateConfigForFile( + protected override async getSourceFileHashAsync( + sourceFile: IExtendedSourceFile | IAdditionalLintFile + ): Promise { + const linter: TEslint.ESLint | TEslintLegacy.ESLint = this._getLinterForSourceFile(sourceFile); + const sourceFileEslintConfiguration: TEslint.Linter.Config = await linter.calculateConfigForFile( sourceFile.fileName ); @@ -272,10 +337,11 @@ export class Eslint extends LinterBase { + const linter: TEslint.ESLint | TEslintLegacy.ESLint = this._getLinterForSourceFile(sourceFile); const lintResults: TEslint.ESLint.LintResult[] | TEslintLegacy.ESLint.LintResult[] = - await this._linter.lintText(sourceFile.text, { filePath: sourceFile.fileName }); + await linter.lintText(sourceFile.text, { filePath: sourceFile.fileName }); // Map the fix messages to the results. This API should only return one result per file, so we can be sure // that the fix messages belong to the returned result. If we somehow receive multiple results, we will @@ -349,8 +415,10 @@ export class Eslint extends LinterBase; + includeAdditionalFiles: boolean; + additionalFileIgnorePatterns: string[]; } function checkFix(taskSession: IHeftTaskSession, pluginOptions?: ILintPluginOptions): boolean { @@ -134,6 +138,14 @@ export default class LintPlugin implements IHeftTaskPlugin { } // Run the linters to completion. Linters emit errors and warnings to the logger. + const additionalFileIgnorePatterns: string[] = await this._getTypeScriptOutputIgnorePatternsAsync( + heftConfiguration, + taskSession, + typescriptChangedFiles.map( + ([tsProgram]: [IExtendedProgram, ReadonlySet]) => tsProgram + ) + ); + let includeAdditionalFiles: boolean = true; for (const [tsProgram, changedFiles] of typescriptChangedFiles) { try { await this._lintAsync({ @@ -142,13 +154,17 @@ export default class LintPlugin implements IHeftTaskPlugin { tsProgram, changedFiles, fix, - sarifLogPath + sarifLogPath, + includeAdditionalFiles, + additionalFileIgnorePatterns }); } catch (error) { if (!(error instanceof AlreadyReportedError)) { taskSession.logger.emitError(error as Error); } } + + includeAdditionalFiles = false; } // Clear the changed files so that we don't lint them again if the task is executed again @@ -222,13 +238,22 @@ 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, + includeAdditionalFiles, + additionalFileIgnorePatterns + } = options; // Ensure that we have initialized. This promise is cached, so calling init // multiple times will only init once. await this._ensureInitializedAsync(taskSession, heftConfiguration); - const linters: LinterBase[] = []; + const lintPromises: Promise[] = []; if (this._eslintConfigFilePath && this._eslintToolPath) { const eslintLinter: Eslint = await Eslint.initializeAsync({ tsProgram, @@ -238,9 +263,21 @@ export default class LintPlugin implements IHeftTaskPlugin { linterToolPath: this._eslintToolPath, linterConfigFilePath: this._eslintConfigFilePath, buildFolderPath: heftConfiguration.buildFolderPath, - buildMetadataFolderPath: taskSession.tempFolderPath + buildMetadataFolderPath: taskSession.tempFolderPath, + additionalFileIgnorePatterns }); - linters.push(eslintLinter); + const additionalFiles: ReadonlySet | undefined = includeAdditionalFiles + ? await eslintLinter.getAdditionalLintFilesAsync() + : undefined; + eslintLinter.printVersionHeader(); + lintPromises.push( + eslintLinter.performLintingAsync({ + tsProgram, + typeScriptFilenames: new Set(tsProgram.getRootFileNames()), + changedFiles: changedFiles || new Set(tsProgram.getSourceFiles()), + additionalFiles + }) + ); } if (this._tslintConfigFilePath && this._tslintToolPath) { @@ -253,25 +290,50 @@ export default class LintPlugin implements IHeftTaskPlugin { buildFolderPath: heftConfiguration.buildFolderPath, buildMetadataFolderPath: taskSession.tempFolderPath }); - linters.push(tslintLinter); + tslintLinter.printVersionHeader(); + lintPromises.push( + tslintLinter.performLintingAsync({ + tsProgram, + typeScriptFilenames: new Set(tsProgram.getRootFileNames()), + changedFiles: changedFiles || new Set(tsProgram.getSourceFiles()) + }) + ); } // Now that we know we have initialized properly, run the linter(s) - await Promise.all(linters.map((linter) => this._runLinterAsync(linter, tsProgram, changedFiles))); + await Promise.all(lintPromises); } - private async _runLinterAsync( - linter: LinterBase, - tsProgram: IExtendedProgram, - changedFiles?: ReadonlySet | undefined - ): Promise { - linter.printVersionHeader(); + private async _getTypeScriptOutputIgnorePatternsAsync( + heftConfiguration: HeftConfiguration, + taskSession: IHeftTaskSession, + tsPrograms: IExtendedProgram[] + ): Promise { + const outputFolderPaths: Set = new Set(); + for (const tsProgram of tsPrograms) { + const { outDir, declarationDir } = tsProgram.getCompilerOptions(); + if (outDir) { + outputFolderPaths.add(outDir); + } - const typeScriptFilenames: Set = new Set(tsProgram.getRootFileNames()); - await linter.performLintingAsync({ - tsProgram, - typeScriptFilenames, - changedFiles: changedFiles || new Set(tsProgram.getSourceFiles()) - }); + if (declarationDir) { + outputFolderPaths.add(declarationDir); + } + } + + const typeScriptConfiguration: ITypeScriptConfigurationJson | undefined = + await loadTypeScriptConfigurationFileAsync(heftConfiguration, taskSession.logger.terminal); + for (const additionalModuleKind of typeScriptConfiguration?.additionalModuleKindsToEmit || []) { + outputFolderPaths.add(additionalModuleKind.outFolderName); + } + + const { buildFolderPath } = heftConfiguration; + return Array.from(outputFolderPaths, (outputFolderPath: string) => { + const relativePath: string = path.relative(buildFolderPath, outputFolderPath).replaceAll('\\', '/'); + return `${relativePath}/**`; + }).filter( + (relativePath: string) => + relativePath !== '/**' && relativePath !== '../**' && !relativePath.startsWith('../') + ); } } diff --git a/heft-plugins/heft-lint-plugin/src/LinterBase.ts b/heft-plugins/heft-lint-plugin/src/LinterBase.ts index f6c473754c7..336abc27fb8 100644 --- a/heft-plugins/heft-lint-plugin/src/LinterBase.ts +++ b/heft-plugins/heft-lint-plugin/src/LinterBase.ts @@ -25,9 +25,17 @@ export interface ILinterBaseOptions { tsProgram: IExtendedProgram; fix?: boolean; sarifLogPath?: string; + additionalFileIgnorePatterns?: string[]; } -export interface IRunLinterOptions { +export interface IAdditionalLintFile { + kind: 'additional'; + fileName: string; + text: string; + version: string; +} + +export interface IRunLinterOptions { tsProgram: IExtendedProgram; /** @@ -39,6 +47,11 @@ export interface IRunLinterOptions { * The set of files that TypeScript has compiled since the last compilation. */ changedFiles: ReadonlySet; + + /** + * Files selected by the linter configuration that are not part of the TypeScript program. + */ + additionalFiles?: ReadonlySet; } interface ILinterCacheData { @@ -61,7 +74,7 @@ interface ILinterCacheData { filesHash?: string; } -export abstract class LinterBase { +export abstract class LinterBase { protected readonly _scopedLogger: IScopedLogger; protected readonly _terminal: ITerminal; protected readonly _buildFolderPath: string; @@ -85,7 +98,7 @@ export abstract class LinterBase { public abstract printVersionHeader(): void; - public async performLintingAsync(options: IRunLinterOptions): Promise { + public async performLintingAsync(options: IRunLinterOptions): Promise { const startTime: number = performance.now(); let fileCount: number = 0; @@ -95,7 +108,12 @@ export abstract class LinterBase { // Collect and sort file paths for stable hashing const relativePathsArray: string[] = []; - for (const file of options.typeScriptFilenames) { + const lintFilenames: Set = new Set(options.typeScriptFilenames); + for (const additionalFile of options.additionalFiles || []) { + lintFilenames.add(additionalFile.fileName); + } + + for (const file of lintFilenames) { // Need to use relative paths to ensure portability. const relative: string = Path.convertToSlashes(path.relative(commonDirectory, file)); relativePaths.set(file, relative); @@ -167,7 +185,14 @@ export abstract class LinterBase { // https://github.com/palantir/tslint/blob/24d29e421828348f616bf761adb3892bcdf51662/src/linter.ts#L161-L179 // Modified to only lint files that have changed and that we care about const lintResults: TLintResult[] = []; - for (const sourceFile of options.tsProgram.getSourceFiles()) { + const sourceFiles: (IExtendedSourceFile | TAdditionalLintFile)[] = [ + ...options.tsProgram.getSourceFiles(), + ...(options.additionalFiles || []) + ]; + const changedFilePaths: Set = new Set( + Array.from(options.changedFiles, (sourceFile: IExtendedSourceFile) => sourceFile.fileName) + ); + for (const sourceFile of sourceFiles) { const filePath: string = sourceFile.fileName; const relative: string | undefined = relativePaths.get(filePath); @@ -181,7 +206,7 @@ export abstract class LinterBase { cachedVersion === '' || version === '' || cachedVersion !== version || - options.changedFiles.has(sourceFile) + changedFilePaths.has(filePath) ) { fileCount++; const results: TLintResult[] = await this.lintFileAsync(sourceFile); @@ -219,7 +244,9 @@ export abstract class LinterBase { this._terminal.writeVerboseLine(`Lint: ${duration}ms (${fileCount} files)`); } - protected async getSourceFileHashAsync(sourceFile: IExtendedSourceFile): Promise { + protected async getSourceFileHashAsync( + sourceFile: IExtendedSourceFile | TAdditionalLintFile + ): Promise { // TypeScript only computes the version during an incremental build. let version: string = sourceFile.version; if (!version) { @@ -234,7 +261,9 @@ export abstract class LinterBase { protected abstract getCacheVersionAsync(): Promise; - protected abstract lintFileAsync(sourceFile: IExtendedSourceFile): Promise; + protected abstract lintFileAsync( + sourceFile: IExtendedSourceFile | TAdditionalLintFile + ): Promise; protected abstract lintingFinishedAsync(lintResults: TLintResult[]): Promise;