From 3c8e8c0e899043ddb27aebb8d85c5c4973968521 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 16 Sep 2026 00:32:54 -0400 Subject: [PATCH 1/9] [heft-lint-plugin] Lint files selected by ESLint flat config Use ESLint's native flat-config enumeration to find files outside the TypeScript program and lint them through the existing cache and reporting pipeline with a single ESLint instance. When a type-aware rule is applied to a file that is not part of the TypeScript program, emit actionable guidance to either exclude the file or lint it with a configuration that does not enable type-aware rules. Also fix the lint issues this surfaces in @rushstack/playwright-browser-tunnel's Playwright config and test fixture files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../playwright.config.ts | 3 + .../tests/testFixture.ts | 12 +- .../.eslint-bulk-suppressions.json | 5 + build-tests/eslint-9-test/eslint.config.js | 9 + .../src/__snapshots__/sarif.test.ts.snap | 43 +++++ .../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 | 157 +++++++++++++++++- .../heft-lint-plugin/src/LintPlugin.ts | 84 ++++++++-- .../heft-lint-plugin/src/LinterBase.ts | 45 ++++- 10 files changed, 334 insertions(+), 34 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/apps/playwright-browser-tunnel/playwright.config.ts b/apps/playwright-browser-tunnel/playwright.config.ts index 5d826145aa7..2e354306e1c 100644 --- a/apps/playwright-browser-tunnel/playwright.config.ts +++ b/apps/playwright-browser-tunnel/playwright.config.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ diff --git a/apps/playwright-browser-tunnel/tests/testFixture.ts b/apps/playwright-browser-tunnel/tests/testFixture.ts index 0f0e0dafc90..ba84f374568 100644 --- a/apps/playwright-browser-tunnel/tests/testFixture.ts +++ b/apps/playwright-browser-tunnel/tests/testFixture.ts @@ -2,14 +2,18 @@ // See LICENSE in the project root for license information. import { test as base } from '@playwright/test'; -import { tunneledBrowser } from '../src/tunneledBrowserConnection'; -export const test = base.extend({ +import { + createTunneledBrowserAsync, + type IDisposableTunneledBrowser +} from '../src/tunneledBrowserConnection'; + +export const test: typeof base = base.extend({ browser: [ async ({ browserName, launchOptions, channel, headless }, use) => { - console.log(`Starting tunnel server for browser: ${browserName}, channel: ${channel}`); + console.info(`Starting tunnel server for browser: ${browserName}, channel: ${channel}`); - await using tunnel = await tunneledBrowser(browserName, { + await using tunnel: IDisposableTunneledBrowser = await createTunneledBrowserAsync(browserName, { channel, headless, ...launchOptions 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..7ce2a8bf711 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,11 @@ Object { "uri": "src/sarif.test.ts", }, }, + Object { + "location": Object { + "uri": "src/non-program.custom", + }, + }, ], "results": Array [ Object { @@ -78,6 +83,36 @@ Object { }, ], }, + Object { + "level": "warning", + "locations": Array [ + Object { + "physicalLocation": Object { + "artifactLocation": Object { + "index": 2, + "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 +135,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..48b46dbbcd7 --- /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": "minor" + } + ] +} diff --git a/heft-plugins/heft-lint-plugin/src/Eslint.ts b/heft-plugins/heft-lint-plugin/src/Eslint.ts index 81ca9c87ac9..b0f2bf6858e 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 { Async, 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'; @@ -81,20 +81,29 @@ const ESLINT_LEGACY_CONFIG_FILENAMES: Set = new Set([ LEGACY_ESLINTRC_JS_FILENAME, LEGACY_ESLINTRC_CJS_FILENAME ]); +const ESLINT_DEFAULT_EXTENSIONS: Set = new Set(['.js', '.mjs', '.cjs']); -export class Eslint extends LinterBase { +// Limits the number of additional files that are read from disk concurrently while enumerating the files to +// lint that are not part of the TypeScript program. +const MAX_ADDITIONAL_FILE_READ_CONCURRENCY: number = 10; + +export class Eslint extends LinterBase< + TEslint.ESLint.LintResult | TEslintLegacy.ESLint.LintResult, + IAdditionalLintFile +> { readonly #eslintPackage: typeof TEslint | typeof TEslintLegacy; readonly #eslintPackageVersion: semver.SemVer; readonly #linter: TEslint.ESLint | TEslintLegacy.ESLint; readonly #eslintTimings: Map = new Map(); - readonly #currentFixMessages: (TEslint.Linter.LintMessage | TEslintLegacy.Linter.LintMessage)[] = - []; + readonly #currentFixMessages: (TEslint.Linter.LintMessage | TEslintLegacy.Linter.LintMessage)[] = []; readonly #fixMessagesByResult: Map< TEslint.ESLint.LintResult | TEslintLegacy.ESLint.LintResult, (TEslint.Linter.LintMessage | TEslintLegacy.Linter.LintMessage)[] > = new Map(); readonly #sarifLogPath: string | undefined; readonly #configHashMap: WeakMap = new WeakMap(); + readonly #fileEnumerator: TEslint.ESLint | undefined; + readonly #typeScriptFilenames: ReadonlySet; protected constructor(options: IEslintOptions) { super('eslint', options); @@ -106,7 +115,8 @@ export class Eslint extends LinterBase path.resolve(buildFolderPath, filePath)) + ); + // ESLint configuration paths are relative to the project folder. Compute the project-relative paths of the + // files in the TypeScript program so that the injected program can be scoped to just those files, and so + // that those files can be excluded when enumerating the additional files to lint. Only files under the + // project folder can be expressed as ESLint configuration patterns. + const typeScriptFilePatterns: string[] = []; + for (const filePath of this.#typeScriptFilenames) { + if (Path.isUnder(filePath, buildFolderPath)) { + // filePath is already an absolute path under buildFolderPath, so strip the prefix (plus the separator) + // instead of recomputing the relative path. + typeScriptFilePatterns.push(Path.convertToSlashes(filePath.slice(buildFolderPath.length + 1))); + } + } + let overrideConfig: TEslint.Linter.Config | TEslintLegacy.Linter.Config | undefined; let fixFn: Exclude; if (fix) { @@ -178,7 +204,12 @@ export class Eslint extends LinterBase= 9) { + const flatEslintPackage: typeof TEslint = eslintPackage as typeof TEslint; + // A separate instance is used purely to enumerate the files selected by the ESLint configuration that are + // not part of the TypeScript program. Rules are disabled so that this pass only resolves the file list. + this.#fileEnumerator = new flatEslintPackage.ESLint({ + cwd: buildFolderPath, + errorOnUnmatchedPattern: false, + overrideConfigFile: linterConfigFilePath, + overrideConfig: { + // This is the label for the flat-config object (used in ESLint debug output/config inspection); it is + // not a plugin reference. It ignores the TypeScript program files so enumeration returns only the + // additional files. + name: `${pluginName}/ignore-typescript-program-files`, + ignores: [...typeScriptFilePatterns, ...(additionalFileIgnorePatterns || [])] + }, + ruleFilter: () => false + }); + } + this.#eslintTimings = eslintTimings; } @@ -250,11 +300,60 @@ export class Eslint extends LinterBase> { + if (!this.#fileEnumerator) { + return new Set(); + } + + const lintResults: TEslint.ESLint.LintResult[] = await this.#fileEnumerator.lintFiles(['.']); + + // ESLint reports absolute file paths, so they can be compared directly against the TypeScript program's + // (already resolved) file paths. Files that ESLint lints by default (for example ".js"/".cjs"/".mjs" + // configuration files) are excluded because they are not TypeScript sources selected by this feature. + const additionalFilePaths: string[] = []; + for (const { filePath } of lintResults) { + if ( + !this.#typeScriptFilenames.has(filePath) && + !ESLINT_DEFAULT_EXTENSIONS.has(path.extname(filePath)) + ) { + additionalFilePaths.push(filePath); + } + } + // Sort for a stable ordering across runs. + additionalFilePaths.sort((left: string, right: string) => { + if (left < right) { + return -1; + } else if (left > right) { + return 1; + } else { + return 0; + } + }); + + const additionalLintFiles: IAdditionalLintFile[] = new Array(additionalFilePaths.length); + await Async.forEachAsync( + additionalFilePaths, + async (filePath: string, index: number) => { + additionalLintFiles[index] = { + kind: 'additional', + fileName: filePath, + text: await FileSystem.readFileAsync(filePath), + version: '' + }; + }, + { concurrency: MAX_ADDITIONAL_FILE_READ_CONCURRENCY } + ); + + return new Set(additionalLintFiles); + } + protected override async getCacheVersionAsync(): Promise { return `${this.#eslintPackageVersion.version}_${process.version}`; } - protected override async getSourceFileHashAsync(sourceFile: IExtendedSourceFile): Promise { + protected override async getSourceFileHashAsync( + sourceFile: IExtendedSourceFile | IAdditionalLintFile + ): Promise { const sourceFileEslintConfiguration: TEslint.Linter.Config = await this.#linter.calculateConfigForFile( sourceFile.fileName ); @@ -272,7 +371,7 @@ export class Eslint extends LinterBase { const lintResults: TEslint.ESLint.LintResult[] | TEslintLegacy.ESLint.LintResult[] = await this.#linter.lintText(sourceFile.text, { filePath: sourceFile.fileName }); @@ -332,7 +431,13 @@ export class Eslint extends LinterBase; + includeAdditionalFiles: boolean; + additionalFileIgnorePatterns: string[]; } function checkFix(taskSession: IHeftTaskSession, pluginOptions?: ILintPluginOptions): boolean { @@ -134,6 +136,13 @@ export default class LintPlugin implements IHeftTaskPlugin { } // Run the linters to completion. Linters emit errors and warnings to the logger. + const additionalFileIgnorePatterns: string[] = this.#getTypeScriptOutputIgnorePatterns( + heftConfiguration, + typescriptChangedFiles.map( + ([tsProgram]: [IExtendedProgram, ReadonlySet]) => tsProgram + ) + ); + let includeAdditionalFiles: boolean = true; for (const [tsProgram, changedFiles] of typescriptChangedFiles) { try { await this.#lintAsync({ @@ -142,13 +151,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 +235,22 @@ export default class LintPlugin implements IHeftTaskPlugin { } 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 lintOperations: (() => Promise)[] = []; if (this.#eslintConfigFilePath && this.#eslintToolPath) { const eslintLinter: Eslint = await Eslint.initializeAsync({ tsProgram, @@ -238,9 +260,13 @@ 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; + lintOperations.push(() => this.#runLinterAsync(eslintLinter, tsProgram, changedFiles, additionalFiles)); } if (this.#tslintConfigFilePath && this.#tslintToolPath) { @@ -253,17 +279,18 @@ export default class LintPlugin implements IHeftTaskPlugin { buildFolderPath: heftConfiguration.buildFolderPath, buildMetadataFolderPath: taskSession.tempFolderPath }); - linters.push(tslintLinter); + lintOperations.push(() => this.#runLinterAsync(tslintLinter, tsProgram, changedFiles)); } // 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(lintOperations.map((lintOperation) => lintOperation())); } - async #runLinterAsync( - linter: LinterBase, + async #runLinterAsync( + linter: LinterBase, tsProgram: IExtendedProgram, - changedFiles?: ReadonlySet | undefined + changedFiles?: ReadonlySet | undefined, + additionalFiles?: ReadonlySet | undefined ): Promise { linter.printVersionHeader(); @@ -271,7 +298,38 @@ export default class LintPlugin implements IHeftTaskPlugin { await linter.performLintingAsync({ tsProgram, typeScriptFilenames, - changedFiles: changedFiles || new Set(tsProgram.getSourceFiles()) + changedFiles: changedFiles || new Set(tsProgram.getSourceFiles()), + additionalFiles }); } + + #getTypeScriptOutputIgnorePatterns( + heftConfiguration: HeftConfiguration, + tsPrograms: IExtendedProgram[] + ): string[] { + const { buildFolderPath } = heftConfiguration; + const outputFolderPaths: Set = new Set(); + for (const tsProgram of tsPrograms) { + const { outDir, declarationDir } = tsProgram.getCompilerOptions(); + if (outDir) { + outputFolderPaths.add(path.resolve(buildFolderPath, outDir)); + } + + if (declarationDir) { + outputFolderPaths.add(path.resolve(buildFolderPath, declarationDir)); + } + } + + const ignorePatterns: string[] = []; + for (const outputFolderPath of outputFolderPaths) { + // Only output folders under the project folder can be expressed as ESLint ignore patterns. + if (Path.isUnder(outputFolderPath, buildFolderPath)) { + ignorePatterns.push( + `${Path.convertToSlashes(outputFolderPath.slice(buildFolderPath.length + 1))}/**` + ); + } + } + + return ignorePatterns; + } } diff --git a/heft-plugins/heft-lint-plugin/src/LinterBase.ts b/heft-plugins/heft-lint-plugin/src/LinterBase.ts index 6e3404e5433..6be70889f92 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; From 659f2d422f0ebac56246981985e2420d0a4ce4a5 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 16 Sep 2026 00:40:12 -0400 Subject: [PATCH 2/9] Wire the without-type-information helper through the node rigs Now that @rushstack/eslint-config is published with the `flat/without-type-information` helper, group the rig's own type-aware rules (localCommonConfig) into a `localTypeAwareRules` set and expose a `without-type-information` helper from `decoupled-local-node-rig` and `local-node-rig` that disables type-aware parsing plus both the base profile's and the rig's type-aware rules. Use it in @rushstack/playwright-browser-tunnel to lint the Playwright config and test files, which are excluded from the TypeScript program. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../eslint.config.js | 8 +- ...flat-config-files_2026-09-16-04-00-00.json | 9 ++ eslint/local-eslint-config/.gitignore | 3 +- .../includes/eslint/flat/profile/_common.js | 88 +++++++++++-------- .../eslint/flat/without-type-information.js | 33 +++++++ .../eslint/flat/without-type-information.js | 4 + 6 files changed, 104 insertions(+), 41 deletions(-) create mode 100644 common/changes/@rushstack/playwright-browser-tunnel/heft-lint-flat-config-files_2026-09-16-04-00-00.json create mode 100644 rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/without-type-information.js create mode 100644 rigs/local-node-rig/profiles/default/includes/eslint/flat/without-type-information.js diff --git a/apps/playwright-browser-tunnel/eslint.config.js b/apps/playwright-browser-tunnel/eslint.config.js index c15e6077310..d30a5ca7bcc 100644 --- a/apps/playwright-browser-tunnel/eslint.config.js +++ b/apps/playwright-browser-tunnel/eslint.config.js @@ -3,6 +3,9 @@ const nodeTrustedToolProfile = require('local-node-rig/profiles/default/includes/eslint/flat/profile/node-trusted-tool'); const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); +const { + withoutTypeInformation +} = require('local-node-rig/profiles/default/includes/eslint/flat/without-type-information'); module.exports = [ ...nodeTrustedToolProfile, @@ -14,5 +17,8 @@ module.exports = [ tsconfigRootDir: __dirname } } - } + }, + // The Playwright config and test files are not part of the project's TypeScript program (they are excluded + // from tsconfig.json), so lint them with only the non-type-aware rules. + ...withoutTypeInformation({ files: ['playwright.config.ts', 'tests/**/*.ts'] }) ]; diff --git a/common/changes/@rushstack/playwright-browser-tunnel/heft-lint-flat-config-files_2026-09-16-04-00-00.json b/common/changes/@rushstack/playwright-browser-tunnel/heft-lint-flat-config-files_2026-09-16-04-00-00.json new file mode 100644 index 00000000000..f508c07fa4b --- /dev/null +++ b/common/changes/@rushstack/playwright-browser-tunnel/heft-lint-flat-config-files_2026-09-16-04-00-00.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/playwright-browser-tunnel", + "comment": "Lint the Playwright config and test files (which are not part of the TypeScript program) with only the non-type-aware rules via the rig's without-type-information helper.", + "type": "none" + } + ] +} diff --git a/eslint/local-eslint-config/.gitignore b/eslint/local-eslint-config/.gitignore index 281714b6678..b030246002f 100644 --- a/eslint/local-eslint-config/.gitignore +++ b/eslint/local-eslint-config/.gitignore @@ -1,3 +1,4 @@ /flat/mixins /flat/patch -/flat/profile \ No newline at end of file +/flat/profile +/flat/without-type-information.js diff --git a/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/profile/_common.js b/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/profile/_common.js index 4b78d53b57d..194a5fdbbc1 100644 --- a/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/profile/_common.js +++ b/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/profile/_common.js @@ -11,7 +11,53 @@ const headersEslintPlugin = require('eslint-plugin-headers'); const nodeImportResolverPath = require.resolve('eslint-import-resolver-node'); +// These localCommonConfig rules require type information (i.e. the TypeScript program). They are grouped +// separately so that TypeScript files which are NOT part of the project's TypeScript program can be linted with +// only the non-type-aware rules. See the "without-type-information" helper. +const localTypeAwareRules = { + // Rationale: Use of `void` to explicitly indicate that a floating promise is expected + // and allowed. + '@typescript-eslint/no-floating-promises': [ + 'error', + { + ignoreVoid: true, + checkThenables: true + } + ], + + // Docs: https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/naming-convention.md + '@typescript-eslint/naming-convention': [ + 'warn', + ...expandNamingConventionSelectors([ + ...commonNamingConventionSelectors, + { + selectors: ['method'], + modifiers: ['async'], + enforceLeadingUnderscoreWhenPrivate: true, + + format: null, + custom: { + regex: '^_?[a-zA-Z]\\w*Async$', + match: true + }, + leadingUnderscore: 'allow', + + filter: { + regex: [ + // Specifically allow ts-command-line's "onExecute" function. + '^onExecute$' + ] + .map((x) => `(${x})`) + .join('|'), + match: false + } + } + ]) + ] +}; + module.exports = { + localTypeAwareRules, localCommonConfig: [ { files: ['**/*.ts', '**/*.tsx'], @@ -42,15 +88,9 @@ module.exports = { // understand where the dependency is coming from. '@rushstack/normalized-imports': 'warn', - // Rationale: Use of `void` to explicitly indicate that a floating promise is expected - // and allowed. - '@typescript-eslint/no-floating-promises': [ - 'error', - { - ignoreVoid: true, - checkThenables: true - } - ], + // Type-aware rules (require the TypeScript program) are grouped in localTypeAwareRules so that files + // outside the TypeScript program can be linted with only the non-type-aware rules. + ...localTypeAwareRules, // Rationale: Redeclaring a variable likely indicates a mistake in the code. 'no-redeclare': 'off', @@ -110,36 +150,6 @@ module.exports = { } ], - // Docs: https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/naming-convention.md - '@typescript-eslint/naming-convention': [ - 'warn', - ...expandNamingConventionSelectors([ - ...commonNamingConventionSelectors, - { - selectors: ['method'], - modifiers: ['async'], - enforceLeadingUnderscoreWhenPrivate: true, - - format: null, - custom: { - regex: '^_?[a-zA-Z]\\w*Async$', - match: true - }, - leadingUnderscore: 'allow', - - filter: { - regex: [ - // Specifically allow ts-command-line's "onExecute" function. - '^onExecute$' - ] - .map((x) => `(${x})`) - .join('|'), - match: false - } - } - ]) - ], - // Require `node:` protocol for imports of Node.js built-in modules 'import/enforce-node-protocol-usage': ['warn', 'always'], diff --git a/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/without-type-information.js b/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/without-type-information.js new file mode 100644 index 00000000000..0dbad56e3bc --- /dev/null +++ b/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/without-type-information.js @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const { + withoutTypeInformation: baseWithoutTypeInformation +} = require('@rushstack/eslint-config/flat/without-type-information'); + +const { localTypeAwareRules } = require('./profile/_common'); + +// Like @rushstack/eslint-config's withoutTypeInformation(), but also disables the type-aware rules that this +// rig layers on top of the profile (localCommonConfig). Use this for TypeScript files that are selected by your +// ESLint configuration but are not part of the project's TypeScript program (for example config files or tests +// that are not included by tsconfig.json). +// +// IMPORTANT: These config objects must be included in your ESLint configuration AFTER the profile. +function withoutTypeInformation({ files }) { + const disabledLocalTypeAwareRules = {}; + for (const ruleName of Object.keys(localTypeAwareRules)) { + disabledLocalTypeAwareRules[ruleName] = 'off'; + } + + return [ + // Disables type-aware parsing and the base profile's type-aware rules. + ...baseWithoutTypeInformation({ files }), + // Also disable the type-aware rules that this rig adds on top of the base profile. + { + files, + rules: disabledLocalTypeAwareRules + } + ]; +} + +module.exports = { withoutTypeInformation }; diff --git a/rigs/local-node-rig/profiles/default/includes/eslint/flat/without-type-information.js b/rigs/local-node-rig/profiles/default/includes/eslint/flat/without-type-information.js new file mode 100644 index 00000000000..26f4bda59df --- /dev/null +++ b/rigs/local-node-rig/profiles/default/includes/eslint/flat/without-type-information.js @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +module.exports = require('local-eslint-config/flat/without-type-information'); From 4e8b4c91b0e5fdbf4fe30e2ba2d332e93968b569 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 16 Sep 2026 00:52:44 -0400 Subject: [PATCH 3/9] [heft-lint-plugin] Address review feedback (simplifications) Simplify the additional-file sort to a default lexicographic sort, note that the enumerator lints relative to `buildFolderPath` (its `cwd`), convert the type-info error helper to a loose function that takes the TypeScript file set and build folder as parameters, and empty the playwright-browser-tunnel change comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...flat-config-files_2026-09-16-04-00-00.json | 2 +- heft-plugins/heft-lint-plugin/src/Eslint.ts | 91 +++++++++---------- 2 files changed, 46 insertions(+), 47 deletions(-) diff --git a/common/changes/@rushstack/playwright-browser-tunnel/heft-lint-flat-config-files_2026-09-16-04-00-00.json b/common/changes/@rushstack/playwright-browser-tunnel/heft-lint-flat-config-files_2026-09-16-04-00-00.json index f508c07fa4b..93e3410ae7a 100644 --- a/common/changes/@rushstack/playwright-browser-tunnel/heft-lint-flat-config-files_2026-09-16-04-00-00.json +++ b/common/changes/@rushstack/playwright-browser-tunnel/heft-lint-flat-config-files_2026-09-16-04-00-00.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/playwright-browser-tunnel", - "comment": "Lint the Playwright config and test files (which are not part of the TypeScript program) with only the non-type-aware rules via the rig's without-type-information helper.", + "comment": "", "type": "none" } ] diff --git a/heft-plugins/heft-lint-plugin/src/Eslint.ts b/heft-plugins/heft-lint-plugin/src/Eslint.ts index b0f2bf6858e..e654f9149e3 100644 --- a/heft-plugins/heft-lint-plugin/src/Eslint.ts +++ b/heft-plugins/heft-lint-plugin/src/Eslint.ts @@ -305,6 +305,8 @@ export class Eslint extends LinterBase< return new Set(); } + // The enumerator ESLint instance is constructed with `cwd: buildFolderPath`, so linting `'.'` resolves + // against the project folder (not the process working directory). const lintResults: TEslint.ESLint.LintResult[] = await this.#fileEnumerator.lintFiles(['.']); // ESLint reports absolute file paths, so they can be compared directly against the TypeScript program's @@ -319,16 +321,9 @@ export class Eslint extends LinterBase< additionalFilePaths.push(filePath); } } - // Sort for a stable ordering across runs. - additionalFilePaths.sort((left: string, right: string) => { - if (left < right) { - return -1; - } else if (left > right) { - return 1; - } else { - return 0; - } - }); + // Sort for a stable ordering across runs. ESLint reports absolute paths, so a default lexicographic sort + // is sufficient. + additionalFilePaths.sort(); const additionalLintFiles: IAdditionalLintFile[] = new Array(additionalFilePaths.length); await Async.forEachAsync( @@ -431,8 +426,12 @@ export class Eslint extends LinterBase< // Report linter errors and warnings to the logger for (const lintMessage of lintResult.messages) { - const additionalFileTypeInformationError: string | undefined = - this.#getAdditionalFileTypeInformationError(lintResult, lintMessage); + const additionalFileTypeInformationError: string | undefined = getAdditionalFileTypeInformationError( + this.#typeScriptFilenames, + this._buildFolderPath, + lintResult, + lintMessage + ); const errorObject: FileError = this.#getLintFileError( lintResult, lintMessage, @@ -485,40 +484,6 @@ export class Eslint extends LinterBase< }); } - #getAdditionalFileTypeInformationError( - lintResult: TEslint.ESLint.LintResult | TEslintLegacy.ESLint.LintResult, - lintMessage: TEslint.Linter.LintMessage | TEslintLegacy.Linter.LintMessage - ): string | undefined { - // ESLint reports a fatal parsing error when a type-aware rule is applied to a file that is not part of any - // TypeScript program or project. Files that are selected by the ESLint configuration but excluded from the - // TypeScript program hit this case, so surface actionable guidance instead of the raw parser error. Files - // that are part of the program (or non-fatal messages) are reported normally. - if (!lintMessage.fatal || this.#typeScriptFilenames.has(lintResult.filePath)) { - return undefined; - } - - const { message } = lintMessage; - const indicatesMissingTypeInformation: boolean = - message.includes('parserOptions.project') || - message.includes('projectService') || - message.includes('program instance') || - message.includes('does not include this file') || - message.includes('not found by the project service'); - if (!indicatesMissingTypeInformation) { - return undefined; - } - - const relativePath: string = Path.convertToSlashes( - path.relative(this._buildFolderPath, lintResult.filePath) - ); - return ( - `The ESLint configuration selected "${relativePath}", which is not part of the TypeScript program, so ` + - 'type-aware rules cannot run on it. Either exclude this file from ESLint by adding it to the "ignores" ' + - 'of your ESLint configuration, or lint it with a configuration that does not enable type-aware rules. ' + - `(ESLint reported: ${message})` - ); - } - #getLintFileError( lintResult: TEslint.ESLint.LintResult | TEslintLegacy.ESLint.LintResult, lintMessage: TEslint.Linter.LintMessage | TEslintLegacy.Linter.LintMessage, @@ -536,3 +501,37 @@ export class Eslint extends LinterBase< }); } } + +function getAdditionalFileTypeInformationError( + typeScriptFilenames: ReadonlySet, + buildFolderPath: string, + lintResult: TEslint.ESLint.LintResult | TEslintLegacy.ESLint.LintResult, + lintMessage: TEslint.Linter.LintMessage | TEslintLegacy.Linter.LintMessage +): string | undefined { + // ESLint reports a fatal parsing error when a type-aware rule is applied to a file that is not part of any + // TypeScript program or project. Files that are selected by the ESLint configuration but excluded from the + // TypeScript program hit this case, so surface actionable guidance instead of the raw parser error. Files + // that are part of the program (or non-fatal messages) are reported normally. + if (!lintMessage.fatal || typeScriptFilenames.has(lintResult.filePath)) { + return undefined; + } + + const { message } = lintMessage; + const indicatesMissingTypeInformation: boolean = + message.includes('parserOptions.project') || + message.includes('projectService') || + message.includes('program instance') || + message.includes('does not include this file') || + message.includes('not found by the project service'); + if (!indicatesMissingTypeInformation) { + return undefined; + } + + const relativePath: string = Path.convertToSlashes(path.relative(buildFolderPath, lintResult.filePath)); + return ( + `The ESLint configuration selected "${relativePath}", which is not part of the TypeScript program, so ` + + 'type-aware rules cannot run on it. Either exclude this file from ESLint by adding it to the "ignores" ' + + 'of your ESLint configuration, or lint it with a configuration that does not enable type-aware rules. ' + + `(ESLint reported: ${message})` + ); +} From ab5f2694e4a1c71717fe929aef0b186306b8a5aa Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 16 Sep 2026 01:14:55 -0400 Subject: [PATCH 4/9] [heft-lint-plugin] Refactor additional-file handling out of LinterBase Address review feedback: - Remove the `additionalFiles` option and the extra-file generic from LinterBase; the base now exposes a neutral `getExtraSourceFilesToLintAsync` hook (default empty) that Eslint overrides to enumerate the files selected by the ESLint configuration. The base only deals with a generic `ISourceFileToLint` shape. - Thread the (project-folder-resolved) TypeScript file set into the enumeration as a parameter instead of reading it from a field, resolving the paths at the LintPlugin call site. - Make `ISourceFileToLint.version` optional (omit it for enumerated files) so the base computes the version from file contents, instead of passing an empty string. - Drop the redundant TypeScript-output-folder ignore patterns; emitted JavaScript is already excluded by the default-JavaScript-extension filter (and emit folders such as `lib-esm` cannot be derived from the compiler options anyway). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- heft-plugins/heft-lint-plugin/src/Eslint.ts | 76 ++++++++++-------- .../heft-lint-plugin/src/LintPlugin.ts | 78 ++++++------------- .../heft-lint-plugin/src/LinterBase.ts | 56 ++++++++----- 3 files changed, 104 insertions(+), 106 deletions(-) diff --git a/heft-plugins/heft-lint-plugin/src/Eslint.ts b/heft-plugins/heft-lint-plugin/src/Eslint.ts index e654f9149e3..43ef652c2e3 100644 --- a/heft-plugins/heft-lint-plugin/src/Eslint.ts +++ b/heft-plugins/heft-lint-plugin/src/Eslint.ts @@ -5,7 +5,6 @@ import path from 'node:path'; import { createHash, type Hash } from 'node:crypto'; import { performance } from 'node:perf_hooks'; -import type * as TTypescript from 'typescript'; import type * as TEslint from 'eslint'; import type * as TEslintLegacy from 'eslint-8'; import * as semver from 'semver'; @@ -14,11 +13,20 @@ import stableStringify from 'json-stable-stringify-without-jsonify'; import { Async, FileError, FileSystem, Path } from '@rushstack/node-core-library'; import type { HeftConfiguration } from '@rushstack/heft'; -import { LinterBase, type IAdditionalLintFile, type ILinterBaseOptions } from './LinterBase'; +import { LinterBase, type ISourceFileToLint, type ILinterBaseOptions } from './LinterBase'; import type { IExtendedSourceFile } from './internalTypings/TypeScriptInternals'; import { name as pluginName, version as pluginVersion } from '../package.json'; -interface IEslintOptions extends ILinterBaseOptions { +interface IEslintInitializeOptions extends ILinterBaseOptions { + /** + * Whether this instance should enumerate and lint files selected by the ESLint configuration that are not + * part of the TypeScript program. Only one instance should do so per lint run (to avoid linting those files + * more than once when there are multiple TypeScript programs). + */ + includeAdditionalFiles?: boolean; +} + +interface IEslintOptions extends IEslintInitializeOptions { eslintPackage: typeof TEslint | typeof TEslintLegacy; eslintTimings: Map; } @@ -81,16 +89,19 @@ const ESLINT_LEGACY_CONFIG_FILENAMES: Set = new Set([ LEGACY_ESLINTRC_JS_FILENAME, LEGACY_ESLINTRC_CJS_FILENAME ]); -const ESLINT_DEFAULT_EXTENSIONS: Set = new Set(['.js', '.mjs', '.cjs']); // Limits the number of additional files that are read from disk concurrently while enumerating the files to // lint that are not part of the TypeScript program. const MAX_ADDITIONAL_FILE_READ_CONCURRENCY: number = 10; -export class Eslint extends LinterBase< - TEslint.ESLint.LintResult | TEslintLegacy.ESLint.LintResult, - IAdditionalLintFile -> { +// ESLint's flat config lints these JavaScript extensions by default, so `lintFiles('.')` would otherwise return +// emitted build output (for example the `lib-commonjs`/`lib-esm` folders). They are excluded from the +// additional-file pass so that generated JavaScript is not linted. Note that emit folders such as `lib-esm` +// cannot be identified from the TypeScript compiler options (they come from additionalModuleKindsToEmit), so an +// extension-based filter is used rather than an output-folder filter. +const ESLINT_DEFAULT_EXTENSIONS: Set = new Set(['.js', '.mjs', '.cjs']); + +export class Eslint extends LinterBase { readonly #eslintPackage: typeof TEslint | typeof TEslintLegacy; readonly #eslintPackageVersion: semver.SemVer; readonly #linter: TEslint.ESLint | TEslintLegacy.ESLint; @@ -104,6 +115,7 @@ export class Eslint extends LinterBase< readonly #configHashMap: WeakMap = new WeakMap(); readonly #fileEnumerator: TEslint.ESLint | undefined; readonly #typeScriptFilenames: ReadonlySet; + readonly #includeAdditionalFiles: boolean; protected constructor(options: IEslintOptions) { super('eslint', options); @@ -116,9 +128,10 @@ export class Eslint extends LinterBase< eslintTimings, fix, sarifLogPath, - additionalFileIgnorePatterns + includeAdditionalFiles } = options; this.#eslintPackage = eslintPackage; + this.#includeAdditionalFiles = includeAdditionalFiles ?? false; this.#eslintPackageVersion = new semver.SemVer(eslintPackage.ESLint.version); const linterConfigFileName: string = path.basename(linterConfigFilePath); if (this.#eslintPackageVersion.major < 9 && !ESLINT_LEGACY_CONFIG_FILENAMES.has(linterConfigFileName)) { @@ -158,7 +171,7 @@ export class Eslint extends LinterBase< let overrideConfig: TEslint.Linter.Config | TEslintLegacy.Linter.Config | undefined; let fixFn: Exclude; if (fix) { - // We do not recieve the messages for the issues that were fixed, so we need to track them ourselves + // We do not receive the messages for the issues that were fixed, so we need to track them ourselves // so that we can log them after the fix is applied. This array will be populated by the fix function, // and subsequently mapped to the results in the ESLint.lintFileAsync method below. After the messages // are mapped, the array will be cleared so that it is ready for the next fix operation. @@ -237,9 +250,9 @@ export class Eslint extends LinterBase< overrideConfig: { // This is the label for the flat-config object (used in ESLint debug output/config inspection); it is // not a plugin reference. It ignores the TypeScript program files so enumeration returns only the - // additional files. + // files that are not part of the program. name: `${pluginName}/ignore-typescript-program-files`, - ignores: [...typeScriptFilePatterns, ...(additionalFileIgnorePatterns || [])] + ignores: typeScriptFilePatterns }, ruleFilter: () => false }); @@ -271,8 +284,8 @@ export class Eslint extends LinterBase< return foundConfigs[0]; } - public static async initializeAsync(options: ILinterBaseOptions): Promise { - const { linterToolPath } = options; + public static async initializeAsync(options: IEslintInitializeOptions): Promise { + const { linterToolPath, includeAdditionalFiles } = options; const eslintTimings: Map = new Map(); // This must happen before the rest of the linter package is loaded await patchTimerAsync(linterToolPath, eslintTimings); @@ -281,7 +294,8 @@ export class Eslint extends LinterBase< return new Eslint({ ...options, eslintPackage, - eslintTimings + eslintTimings, + includeAdditionalFiles }); } @@ -300,9 +314,11 @@ export class Eslint extends LinterBase< } } - public async getAdditionalLintFilesAsync(): Promise> { - if (!this.#fileEnumerator) { - return new Set(); + protected override async getExtraSourceFilesToLintAsync( + typeScriptFilenames: ReadonlySet + ): Promise> { + if (!this.#includeAdditionalFiles || !this.#fileEnumerator) { + return []; } // The enumerator ESLint instance is constructed with `cwd: buildFolderPath`, so linting `'.'` resolves @@ -310,14 +326,12 @@ export class Eslint extends LinterBase< const lintResults: TEslint.ESLint.LintResult[] = await this.#fileEnumerator.lintFiles(['.']); // ESLint reports absolute file paths, so they can be compared directly against the TypeScript program's - // (already resolved) file paths. Files that ESLint lints by default (for example ".js"/".cjs"/".mjs" - // configuration files) are excluded because they are not TypeScript sources selected by this feature. + // (already resolved) file paths. Files that are part of the program are excluded, as are ESLint's default + // JavaScript extensions (see ESLINT_DEFAULT_EXTENSIONS); everything else the ESLint configuration selects + // (and does not ignore) is linted as an additional file. const additionalFilePaths: string[] = []; for (const { filePath } of lintResults) { - if ( - !this.#typeScriptFilenames.has(filePath) && - !ESLINT_DEFAULT_EXTENSIONS.has(path.extname(filePath)) - ) { + if (!typeScriptFilenames.has(filePath) && !ESLINT_DEFAULT_EXTENSIONS.has(path.extname(filePath))) { additionalFilePaths.push(filePath); } } @@ -325,21 +339,21 @@ export class Eslint extends LinterBase< // is sufficient. additionalFilePaths.sort(); - const additionalLintFiles: IAdditionalLintFile[] = new Array(additionalFilePaths.length); + const additionalLintFiles: ISourceFileToLint[] = new Array(additionalFilePaths.length); await Async.forEachAsync( additionalFilePaths, async (filePath: string, index: number) => { additionalLintFiles[index] = { - kind: 'additional', fileName: filePath, - text: await FileSystem.readFileAsync(filePath), - version: '' + // `version` is intentionally omitted so that LinterBase computes it from the file contents. Unlike + // TypeScript source files, these files have no precomputed version from the incremental program. + text: await FileSystem.readFileAsync(filePath) }; }, { concurrency: MAX_ADDITIONAL_FILE_READ_CONCURRENCY } ); - return new Set(additionalLintFiles); + return additionalLintFiles; } protected override async getCacheVersionAsync(): Promise { @@ -347,7 +361,7 @@ export class Eslint extends LinterBase< } protected override async getSourceFileHashAsync( - sourceFile: IExtendedSourceFile | IAdditionalLintFile + sourceFile: IExtendedSourceFile | ISourceFileToLint ): Promise { const sourceFileEslintConfiguration: TEslint.Linter.Config = await this.#linter.calculateConfigForFile( sourceFile.fileName @@ -366,7 +380,7 @@ export class Eslint extends LinterBase< } protected override async lintFileAsync( - sourceFile: TTypescript.SourceFile | IAdditionalLintFile + sourceFile: IExtendedSourceFile | ISourceFileToLint ): Promise { const lintResults: TEslint.ESLint.LintResult[] | TEslintLegacy.ESLint.LintResult[] = await this.#linter.lintText(sourceFile.text, { filePath: sourceFile.fileName }); diff --git a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts index 272e46836ea..7c867385e0c 100644 --- a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts +++ b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts @@ -17,9 +17,9 @@ import type { IChangedFilesHookOptions, ITypeScriptPluginAccessor } from '@rushstack/heft-typescript-plugin'; -import { AlreadyReportedError, Path } from '@rushstack/node-core-library'; +import { AlreadyReportedError } from '@rushstack/node-core-library'; -import type { IAdditionalLintFile, LinterBase } from './LinterBase'; +import type { LinterBase } from './LinterBase'; import { Eslint } from './Eslint'; import { Tslint } from './Tslint'; import type { IExtendedProgram, IExtendedSourceFile } from './internalTypings/TypeScriptInternals'; @@ -43,7 +43,6 @@ interface ILintOptions { sarifLogPath?: string; changedFiles?: ReadonlySet; includeAdditionalFiles: boolean; - additionalFileIgnorePatterns: string[]; } function checkFix(taskSession: IHeftTaskSession, pluginOptions?: ILintPluginOptions): boolean { @@ -136,12 +135,6 @@ export default class LintPlugin implements IHeftTaskPlugin { } // Run the linters to completion. Linters emit errors and warnings to the logger. - const additionalFileIgnorePatterns: string[] = this.#getTypeScriptOutputIgnorePatterns( - heftConfiguration, - typescriptChangedFiles.map( - ([tsProgram]: [IExtendedProgram, ReadonlySet]) => tsProgram - ) - ); let includeAdditionalFiles: boolean = true; for (const [tsProgram, changedFiles] of typescriptChangedFiles) { try { @@ -152,8 +145,7 @@ export default class LintPlugin implements IHeftTaskPlugin { changedFiles, fix, sarifLogPath, - includeAdditionalFiles, - additionalFileIgnorePatterns + includeAdditionalFiles }); } catch (error) { if (!(error instanceof AlreadyReportedError)) { @@ -242,8 +234,7 @@ export default class LintPlugin implements IHeftTaskPlugin { changedFiles, fix, sarifLogPath, - includeAdditionalFiles, - additionalFileIgnorePatterns + includeAdditionalFiles } = options; // Ensure that we have initialized. This promise is cached, so calling init @@ -261,12 +252,11 @@ export default class LintPlugin implements IHeftTaskPlugin { linterConfigFilePath: this.#eslintConfigFilePath, buildFolderPath: heftConfiguration.buildFolderPath, buildMetadataFolderPath: taskSession.tempFolderPath, - additionalFileIgnorePatterns + includeAdditionalFiles }); - const additionalFiles: ReadonlySet | undefined = includeAdditionalFiles - ? await eslintLinter.getAdditionalLintFilesAsync() - : undefined; - lintOperations.push(() => this.#runLinterAsync(eslintLinter, tsProgram, changedFiles, additionalFiles)); + lintOperations.push(() => + this.#runLinterAsync(eslintLinter, heftConfiguration, tsProgram, changedFiles) + ); } if (this.#tslintConfigFilePath && this.#tslintToolPath) { @@ -279,57 +269,33 @@ export default class LintPlugin implements IHeftTaskPlugin { buildFolderPath: heftConfiguration.buildFolderPath, buildMetadataFolderPath: taskSession.tempFolderPath }); - lintOperations.push(() => this.#runLinterAsync(tslintLinter, tsProgram, changedFiles)); + lintOperations.push(() => + this.#runLinterAsync(tslintLinter, heftConfiguration, tsProgram, changedFiles) + ); } // Now that we know we have initialized properly, run the linter(s) await Promise.all(lintOperations.map((lintOperation) => lintOperation())); } - async #runLinterAsync( - linter: LinterBase, + async #runLinterAsync( + linter: LinterBase, + heftConfiguration: HeftConfiguration, tsProgram: IExtendedProgram, - changedFiles?: ReadonlySet | undefined, - additionalFiles?: ReadonlySet | undefined + changedFiles?: ReadonlySet | undefined ): Promise { linter.printVersionHeader(); - const typeScriptFilenames: Set = new Set(tsProgram.getRootFileNames()); + // Resolve the program's root file names against the project folder so that they can be compared against the + // absolute paths that ESLint reports for the files it selects. + const { buildFolderPath } = heftConfiguration; + const typeScriptFilenames: Set = new Set( + tsProgram.getRootFileNames().map((filePath: string) => path.resolve(buildFolderPath, filePath)) + ); await linter.performLintingAsync({ tsProgram, typeScriptFilenames, - changedFiles: changedFiles || new Set(tsProgram.getSourceFiles()), - additionalFiles + changedFiles: changedFiles || new Set(tsProgram.getSourceFiles()) }); } - - #getTypeScriptOutputIgnorePatterns( - heftConfiguration: HeftConfiguration, - tsPrograms: IExtendedProgram[] - ): string[] { - const { buildFolderPath } = heftConfiguration; - const outputFolderPaths: Set = new Set(); - for (const tsProgram of tsPrograms) { - const { outDir, declarationDir } = tsProgram.getCompilerOptions(); - if (outDir) { - outputFolderPaths.add(path.resolve(buildFolderPath, outDir)); - } - - if (declarationDir) { - outputFolderPaths.add(path.resolve(buildFolderPath, declarationDir)); - } - } - - const ignorePatterns: string[] = []; - for (const outputFolderPath of outputFolderPaths) { - // Only output folders under the project folder can be expressed as ESLint ignore patterns. - if (Path.isUnder(outputFolderPath, buildFolderPath)) { - ignorePatterns.push( - `${Path.convertToSlashes(outputFolderPath.slice(buildFolderPath.length + 1))}/**` - ); - } - } - - return ignorePatterns; - } } diff --git a/heft-plugins/heft-lint-plugin/src/LinterBase.ts b/heft-plugins/heft-lint-plugin/src/LinterBase.ts index 6be70889f92..ca7c0d9159e 100644 --- a/heft-plugins/heft-lint-plugin/src/LinterBase.ts +++ b/heft-plugins/heft-lint-plugin/src/LinterBase.ts @@ -25,17 +25,24 @@ export interface ILinterBaseOptions { tsProgram: IExtendedProgram; fix?: boolean; sarifLogPath?: string; - additionalFileIgnorePatterns?: string[]; } -export interface IAdditionalLintFile { - kind: 'additional'; +/** + * A file to lint that is not necessarily part of the TypeScript program (for example a file discovered by the + * linter configuration itself). TypeScript source files also satisfy this shape. + */ +export interface ISourceFileToLint { fileName: string; text: string; - version: string; + /** + * A precomputed version identifier used for incremental caching. TypeScript source files carry a version from + * the incremental program; other files may omit it, in which case the version is computed from the file + * contents. + */ + version?: string; } -export interface IRunLinterOptions { +export interface IRunLinterOptions { tsProgram: IExtendedProgram; /** @@ -47,11 +54,6 @@ export interface IRunLinterOptions; - - /** - * Files selected by the linter configuration that are not part of the TypeScript program. - */ - additionalFiles?: ReadonlySet; } interface ILinterCacheData { @@ -74,7 +76,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; @@ -98,19 +100,25 @@ export abstract class LinterBase): Promise { + public async performLintingAsync(options: IRunLinterOptions): Promise { const startTime: number = performance.now(); let fileCount: number = 0; const commonDirectory: string = options.tsProgram.getCommonSourceDirectory(); + // Files to lint that are not part of the TypeScript program (subclasses may enumerate their own). The + // default implementation returns none. + const extraSourceFiles: Iterable = await this.getExtraSourceFilesToLintAsync( + options.typeScriptFilenames + ); + const relativePaths: Map = new Map(); // Collect and sort file paths for stable hashing const relativePathsArray: string[] = []; const lintFilenames: Set = new Set(options.typeScriptFilenames); - for (const additionalFile of options.additionalFiles || []) { - lintFilenames.add(additionalFile.fileName); + for (const extraSourceFile of extraSourceFiles) { + lintFilenames.add(extraSourceFile.fileName); } for (const file of lintFilenames) { @@ -185,9 +193,9 @@ export abstract class LinterBase = new Set( Array.from(options.changedFiles, (sourceFile: IExtendedSourceFile) => sourceFile.fileName) @@ -244,11 +252,21 @@ export abstract class LinterBase + ): Promise> { + return []; + } + protected async getSourceFileHashAsync( - sourceFile: IExtendedSourceFile | TAdditionalLintFile + sourceFile: IExtendedSourceFile | ISourceFileToLint ): Promise { // TypeScript only computes the version during an incremental build. - let version: string = sourceFile.version; + let version: string | undefined = sourceFile.version; if (!version) { // Compute the version from the source file content const sourceFileHash: Hash = createHash('sha1'); @@ -262,7 +280,7 @@ export abstract class LinterBase; protected abstract lintFileAsync( - sourceFile: IExtendedSourceFile | TAdditionalLintFile + sourceFile: IExtendedSourceFile | ISourceFileToLint ): Promise; protected abstract lintingFinishedAsync(lintResults: TLintResult[]): Promise; From 92fd5dce2f0f59d96ac2cdf377bec7ee9ab3ea6a Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 16 Sep 2026 13:56:22 -0400 Subject: [PATCH 5/9] Ignore TypeScript output folders via the TypeScript plugin accessor Instead of blanket-ignoring ESLint's default JavaScript extensions (which also prevented authored `.js` files from being linted), discover the TypeScript output folders and ignore only those when enumerating additional files. - heft-typescript-plugin: expose `emitFolderPaths` on `IChangedFilesHookOptions` (the `outDir`/`declarationDir` plus any `additionalModuleKindsToEmit` folders, such as `lib-esm`, which are not part of the compiler options). - heft-lint-plugin: aggregate those folders from the accessor and ignore them in the additional-file enumerator, and remove the default-JavaScript-extension filter so that authored `.js` files selected by the ESLint configuration are linted. Update the eslint-9-test snapshot, which now lints `eslint.config.js` while still excluding the emitted `lib-commonjs`/`lib-esm` output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/__snapshots__/sarif.test.ts.snap | 7 +++- ...nfig-emit-folders_2026-09-16-05-00-00.json | 9 ++++ .../reviews/api/heft-typescript-plugin.api.md | 1 + heft-plugins/heft-lint-plugin/src/Eslint.ts | 41 ++++++++++++------- .../heft-lint-plugin/src/LintPlugin.ts | 28 ++++++++++++- .../src/TypeScriptPlugin.ts | 26 +++++++++++- 6 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 common/changes/@rushstack/heft-typescript-plugin/heft-lint-flat-config-emit-folders_2026-09-16-05-00-00.json 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 7ce2a8bf711..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,11 @@ Object { "uri": "src/sarif.test.ts", }, }, + Object { + "location": Object { + "uri": "eslint.config.js", + }, + }, Object { "location": Object { "uri": "src/non-program.custom", @@ -89,7 +94,7 @@ Object { Object { "physicalLocation": Object { "artifactLocation": Object { - "index": 2, + "index": 3, "uri": "src/non-program.custom", }, "region": Object { diff --git a/common/changes/@rushstack/heft-typescript-plugin/heft-lint-flat-config-emit-folders_2026-09-16-05-00-00.json b/common/changes/@rushstack/heft-typescript-plugin/heft-lint-flat-config-emit-folders_2026-09-16-05-00-00.json new file mode 100644 index 00000000000..1983a8863a0 --- /dev/null +++ b/common/changes/@rushstack/heft-typescript-plugin/heft-lint-flat-config-emit-folders_2026-09-16-05-00-00.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-typescript-plugin", + "comment": "Add `emitFolderPaths` to the `IChangedFilesHookOptions` provided by the TypeScript plugin accessor, so that consumers can identify (and avoid processing) the folders that TypeScript emits output to, including `additionalModuleKindsToEmit` folders.", + "type": "minor" + } + ] +} diff --git a/common/reviews/api/heft-typescript-plugin.api.md b/common/reviews/api/heft-typescript-plugin.api.md index 51276bd9191..331ed2fded3 100644 --- a/common/reviews/api/heft-typescript-plugin.api.md +++ b/common/reviews/api/heft-typescript-plugin.api.md @@ -29,6 +29,7 @@ export interface _IBaseTypeScriptTool; + emitFolderPaths: ReadonlySet; // (undocumented) program: _TTypeScript.Program; } diff --git a/heft-plugins/heft-lint-plugin/src/Eslint.ts b/heft-plugins/heft-lint-plugin/src/Eslint.ts index 43ef652c2e3..58479ec0325 100644 --- a/heft-plugins/heft-lint-plugin/src/Eslint.ts +++ b/heft-plugins/heft-lint-plugin/src/Eslint.ts @@ -24,6 +24,11 @@ interface IEslintInitializeOptions extends ILinterBaseOptions { * more than once when there are multiple TypeScript programs). */ includeAdditionalFiles?: boolean; + /** + * The absolute paths of the folders that TypeScript emits output to. These are ignored when enumerating the + * additional files to lint so that generated output is not linted. + */ + emitFolderPaths?: ReadonlySet; } interface IEslintOptions extends IEslintInitializeOptions { @@ -94,13 +99,6 @@ const ESLINT_LEGACY_CONFIG_FILENAMES: Set = new Set([ // lint that are not part of the TypeScript program. const MAX_ADDITIONAL_FILE_READ_CONCURRENCY: number = 10; -// ESLint's flat config lints these JavaScript extensions by default, so `lintFiles('.')` would otherwise return -// emitted build output (for example the `lib-commonjs`/`lib-esm` folders). They are excluded from the -// additional-file pass so that generated JavaScript is not linted. Note that emit folders such as `lib-esm` -// cannot be identified from the TypeScript compiler options (they come from additionalModuleKindsToEmit), so an -// extension-based filter is used rather than an output-folder filter. -const ESLINT_DEFAULT_EXTENSIONS: Set = new Set(['.js', '.mjs', '.cjs']); - export class Eslint extends LinterBase { readonly #eslintPackage: typeof TEslint | typeof TEslintLegacy; readonly #eslintPackageVersion: semver.SemVer; @@ -128,7 +126,8 @@ export class Eslint extends LinterBase; if (fix) { @@ -249,10 +259,11 @@ export class Eslint extends LinterBase false }); @@ -326,12 +337,12 @@ export class Eslint extends LinterBase; includeAdditionalFiles: boolean; + /** + * The absolute paths of the folders that TypeScript emits output to, ignored when enumerating additional + * files so that generated output is not linted. + */ + emitFolderPaths: ReadonlySet; } function checkFix(taskSession: IHeftTaskSession, pluginOptions?: ILintPluginOptions): boolean { @@ -106,6 +111,9 @@ export default class LintPlugin implements IHeftTaskPlugin { // Use the changed files hook to collect the files and programs from TypeScript let typescriptChangedFiles: [IExtendedProgram, ReadonlySet][] = []; + // The absolute paths of the folders that TypeScript emits output to, aggregated across all programs. These + // are ignored when enumerating the additional files to lint so that generated output is not linted. + const emitFolderPaths: Set = new Set(); taskSession.requestAccessToPluginByName( TYPESCRIPT_PLUGIN_PACKAGE_NAME, TYPESCRIPT_PLUGIN_NAME, @@ -119,6 +127,9 @@ export default class LintPlugin implements IHeftTaskPlugin { changedFilesHookOptions.program as IExtendedProgram, changedFilesHookOptions.changedFiles as ReadonlySet ]); + for (const emitFolderPath of changedFilesHookOptions.emitFolderPaths) { + emitFolderPaths.add(emitFolderPath); + } }); } ); @@ -132,6 +143,16 @@ export default class LintPlugin implements IHeftTaskPlugin { taskSession ); typescriptChangedFiles.push([tsProgram, new Set(tsProgram.getSourceFiles())]); + // In standalone mode there is no TypeScript plugin to report emit folders, so derive them from the + // program's compiler options. (additionalModuleKindsToEmit output folders are not available here.) + const { outDir, declarationDir } = tsProgram.getCompilerOptions(); + if (outDir) { + emitFolderPaths.add(path.resolve(heftConfiguration.buildFolderPath, outDir)); + } + + if (declarationDir) { + emitFolderPaths.add(path.resolve(heftConfiguration.buildFolderPath, declarationDir)); + } } // Run the linters to completion. Linters emit errors and warnings to the logger. @@ -145,7 +166,8 @@ export default class LintPlugin implements IHeftTaskPlugin { changedFiles, fix, sarifLogPath, - includeAdditionalFiles + includeAdditionalFiles, + emitFolderPaths }); } catch (error) { if (!(error instanceof AlreadyReportedError)) { @@ -234,7 +256,8 @@ export default class LintPlugin implements IHeftTaskPlugin { changedFiles, fix, sarifLogPath, - includeAdditionalFiles + includeAdditionalFiles, + emitFolderPaths } = options; // Ensure that we have initialized. This promise is cached, so calling init @@ -247,6 +270,7 @@ export default class LintPlugin implements IHeftTaskPlugin { tsProgram, fix, sarifLogPath, + emitFolderPaths, scopedLogger: taskSession.logger, linterToolPath: this.#eslintToolPath, linterConfigFilePath: this.#eslintConfigFilePath, diff --git a/heft-plugins/heft-typescript-plugin/src/TypeScriptPlugin.ts b/heft-plugins/heft-typescript-plugin/src/TypeScriptPlugin.ts index 32dff7658e2..6372a4b76a3 100644 --- a/heft-plugins/heft-typescript-plugin/src/TypeScriptPlugin.ts +++ b/heft-plugins/heft-typescript-plugin/src/TypeScriptPlugin.ts @@ -128,6 +128,12 @@ export interface IPartialTsconfig { export interface IChangedFilesHookOptions { program: TTypescript.Program; changedFiles?: ReadonlySet; + /** + * The absolute paths of the folders that the TypeScript compiler emits output to. This includes the + * `outDir` and `declarationDir` from the compiler options as well as any `additionalModuleKindsToEmit` + * output folders (for example `lib-esm`). Consumers can use these to avoid processing generated output. + */ + emitFolderPaths: ReadonlySet; } /** @@ -381,7 +387,25 @@ export default class TypeScriptPlugin implements IHeftTaskPlugin { ) => { // Provide the typescript program dependent plugins if (this.accessor.onChangedFilesHook.isUsed()) { - this.accessor.onChangedFilesHook.call({ program, changedFiles }); + // Collect the folders that the compiler emits output to so that consumers can avoid processing + // generated output. `additionalModuleKindsToEmit` output folders (for example `lib-esm`) are not + // part of the compiler options, so they must be included from the Heft configuration. + const compilerOptions: TTypescript.CompilerOptions = program.getCompilerOptions(); + const emitFolderPaths: Set = new Set(); + const { outDir, declarationDir } = compilerOptions; + if (outDir) { + emitFolderPaths.add(path.resolve(heftConfiguration.buildFolderPath, outDir)); + } + + if (declarationDir) { + emitFolderPaths.add(path.resolve(heftConfiguration.buildFolderPath, declarationDir)); + } + + for (const { outFolderName } of typeScriptConfigurationJson?.additionalModuleKindsToEmit ?? []) { + emitFolderPaths.add(path.resolve(heftConfiguration.buildFolderPath, outFolderName)); + } + + this.accessor.onChangedFilesHook.call({ program, changedFiles, emitFolderPaths }); } } }; From f4673c22774264218244b387bffe8da6b3dfdf4d Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sun, 20 Sep 2026 17:50:33 -0400 Subject: [PATCH 6/9] Ignore build-output folders in the ESLint config instead of filtering by extension Now that the additional-file enumeration lints authored JavaScript (not just TypeScript sources), ESLint's `lintFiles('.')` would walk into generated output, which ESLint's flat config does not exclude (it does not respect `.gitignore`). Rather than blanket-ignoring `.js`/`.mjs`/`.cjs`, ignore build output explicitly: - @rushstack/eslint-config: globally ignore `lib`, `lib-*`, `dist`, `temp`, and `coverage` (anchored to the project root). - decoupled-local-node-rig: repeat the same global ignores so projects consuming the currently-published @rushstack/eslint-config via this rig also get them (to be removed once that dependency is bumped). - Add project-level ignores for non-standard generated/fixture folders: package-extractor `test-output`, rush-redis-cobuild sandbox `sandbox`, and the rush vscode extension `webview` bundle output. - Revert the `emitFolderPaths` TypeScript-plugin accessor addition (and its consumption), since the explicit config ignores cover the build output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../eslint.config.js | 5 +++ ...nore-build-output_2026-09-20-04-00-00.json | 9 ++++++ ...nfig-emit-folders_2026-09-16-05-00-00.json | 9 ------ ...gnore-test-output_2026-09-20-04-00-00.json | 9 ++++++ .../reviews/api/heft-typescript-plugin.api.md | 1 - .../eslint-config/src/flat/profile/_common.ts | 7 +++++ heft-plugins/heft-lint-plugin/src/Eslint.ts | 31 +++++-------------- .../heft-lint-plugin/src/LintPlugin.ts | 28 ++--------------- .../src/TypeScriptPlugin.ts | 26 +--------------- libraries/package-extractor/eslint.config.js | 4 +++ .../includes/eslint/flat/profile/_common.js | 8 +++++ .../rush-vscode-extension/eslint.config.js | 9 +++++- 12 files changed, 60 insertions(+), 86 deletions(-) create mode 100644 common/changes/@rushstack/eslint-config/eslint-config-ignore-build-output_2026-09-20-04-00-00.json delete mode 100644 common/changes/@rushstack/heft-typescript-plugin/heft-lint-flat-config-emit-folders_2026-09-16-05-00-00.json create mode 100644 common/changes/@rushstack/package-extractor/heft-lint-flat-config-ignore-test-output_2026-09-20-04-00-00.json diff --git a/build-tests/rush-redis-cobuild-plugin-integration-test/eslint.config.js b/build-tests/rush-redis-cobuild-plugin-integration-test/eslint.config.js index 95db6d06e12..e7bdb962f49 100644 --- a/build-tests/rush-redis-cobuild-plugin-integration-test/eslint.config.js +++ b/build-tests/rush-redis-cobuild-plugin-integration-test/eslint.config.js @@ -5,6 +5,11 @@ const nodeProfile = require('local-node-rig/profiles/default/includes/eslint/fla module.exports = [ ...nodeProfile, + // The sandbox contains checked-in fixture repositories (including bootstrap scripts) that are not source + // code for this project and should not be linted. + { + ignores: ['sandbox/**'] + }, { files: ['**/*.ts', '**/*.tsx'], languageOptions: { diff --git a/common/changes/@rushstack/eslint-config/eslint-config-ignore-build-output_2026-09-20-04-00-00.json b/common/changes/@rushstack/eslint-config/eslint-config-ignore-build-output_2026-09-20-04-00-00.json new file mode 100644 index 00000000000..c4ee77c9081 --- /dev/null +++ b/common/changes/@rushstack/eslint-config/eslint-config-ignore-build-output_2026-09-20-04-00-00.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/eslint-config", + "comment": "Globally ignore build-output folders (`lib`, `lib-*`, `dist`, `temp`, `coverage`) so that generated output is not linted, which matters for tools that enumerate files from the ESLint configuration (ESLint's flat config does not respect `.gitignore`).", + "type": "minor" + } + ] +} diff --git a/common/changes/@rushstack/heft-typescript-plugin/heft-lint-flat-config-emit-folders_2026-09-16-05-00-00.json b/common/changes/@rushstack/heft-typescript-plugin/heft-lint-flat-config-emit-folders_2026-09-16-05-00-00.json deleted file mode 100644 index 1983a8863a0..00000000000 --- a/common/changes/@rushstack/heft-typescript-plugin/heft-lint-flat-config-emit-folders_2026-09-16-05-00-00.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "changes": [ - { - "packageName": "@rushstack/heft-typescript-plugin", - "comment": "Add `emitFolderPaths` to the `IChangedFilesHookOptions` provided by the TypeScript plugin accessor, so that consumers can identify (and avoid processing) the folders that TypeScript emits output to, including `additionalModuleKindsToEmit` folders.", - "type": "minor" - } - ] -} diff --git a/common/changes/@rushstack/package-extractor/heft-lint-flat-config-ignore-test-output_2026-09-20-04-00-00.json b/common/changes/@rushstack/package-extractor/heft-lint-flat-config-ignore-test-output_2026-09-20-04-00-00.json new file mode 100644 index 00000000000..1be1fabd012 --- /dev/null +++ b/common/changes/@rushstack/package-extractor/heft-lint-flat-config-ignore-test-output_2026-09-20-04-00-00.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/package-extractor", + "comment": "", + "type": "none" + } + ] +} diff --git a/common/reviews/api/heft-typescript-plugin.api.md b/common/reviews/api/heft-typescript-plugin.api.md index 331ed2fded3..51276bd9191 100644 --- a/common/reviews/api/heft-typescript-plugin.api.md +++ b/common/reviews/api/heft-typescript-plugin.api.md @@ -29,7 +29,6 @@ export interface _IBaseTypeScriptTool; - emitFolderPaths: ReadonlySet; // (undocumented) program: _TTypeScript.Program; } diff --git a/eslint/eslint-config/src/flat/profile/_common.ts b/eslint/eslint-config/src/flat/profile/_common.ts index ae4812001b8..ab2814f2f81 100644 --- a/eslint/eslint-config/src/flat/profile/_common.ts +++ b/eslint/eslint-config/src/flat/profile/_common.ts @@ -242,6 +242,13 @@ const commonConfig: Linter.Config[] = [ // so we simply ignore them. globalIgnores(['**/*.d.ts']) as Linter.Config, + // Build output and other generated folders are not source code and should never be linted. This is + // particularly important for tools that enumerate files from the ESLint configuration itself (rather than + // only linting a known set of source files), since ESLint's flat config does not respect ".gitignore". + // These patterns are anchored to the project root (they are evaluated relative to the cwd), so a source + // folder such as "src/lib" is not affected. + globalIgnores(['lib/**', 'lib-*/**', 'dist/**', 'temp/**', 'coverage/**']) as Linter.Config, + { files: ['**/*.ts', '**/*.tsx'], languageOptions: { diff --git a/heft-plugins/heft-lint-plugin/src/Eslint.ts b/heft-plugins/heft-lint-plugin/src/Eslint.ts index 58479ec0325..d2c8f763744 100644 --- a/heft-plugins/heft-lint-plugin/src/Eslint.ts +++ b/heft-plugins/heft-lint-plugin/src/Eslint.ts @@ -24,11 +24,6 @@ interface IEslintInitializeOptions extends ILinterBaseOptions { * more than once when there are multiple TypeScript programs). */ includeAdditionalFiles?: boolean; - /** - * The absolute paths of the folders that TypeScript emits output to. These are ignored when enumerating the - * additional files to lint so that generated output is not linted. - */ - emitFolderPaths?: ReadonlySet; } interface IEslintOptions extends IEslintInitializeOptions { @@ -126,8 +121,7 @@ export class Eslint extends LinterBase; if (fix) { @@ -259,11 +242,10 @@ export class Eslint extends LinterBase false }); @@ -338,8 +320,9 @@ export class Eslint extends LinterBase; includeAdditionalFiles: boolean; - /** - * The absolute paths of the folders that TypeScript emits output to, ignored when enumerating additional - * files so that generated output is not linted. - */ - emitFolderPaths: ReadonlySet; } function checkFix(taskSession: IHeftTaskSession, pluginOptions?: ILintPluginOptions): boolean { @@ -111,9 +106,6 @@ export default class LintPlugin implements IHeftTaskPlugin { // Use the changed files hook to collect the files and programs from TypeScript let typescriptChangedFiles: [IExtendedProgram, ReadonlySet][] = []; - // The absolute paths of the folders that TypeScript emits output to, aggregated across all programs. These - // are ignored when enumerating the additional files to lint so that generated output is not linted. - const emitFolderPaths: Set = new Set(); taskSession.requestAccessToPluginByName( TYPESCRIPT_PLUGIN_PACKAGE_NAME, TYPESCRIPT_PLUGIN_NAME, @@ -127,9 +119,6 @@ export default class LintPlugin implements IHeftTaskPlugin { changedFilesHookOptions.program as IExtendedProgram, changedFilesHookOptions.changedFiles as ReadonlySet ]); - for (const emitFolderPath of changedFilesHookOptions.emitFolderPaths) { - emitFolderPaths.add(emitFolderPath); - } }); } ); @@ -143,16 +132,6 @@ export default class LintPlugin implements IHeftTaskPlugin { taskSession ); typescriptChangedFiles.push([tsProgram, new Set(tsProgram.getSourceFiles())]); - // In standalone mode there is no TypeScript plugin to report emit folders, so derive them from the - // program's compiler options. (additionalModuleKindsToEmit output folders are not available here.) - const { outDir, declarationDir } = tsProgram.getCompilerOptions(); - if (outDir) { - emitFolderPaths.add(path.resolve(heftConfiguration.buildFolderPath, outDir)); - } - - if (declarationDir) { - emitFolderPaths.add(path.resolve(heftConfiguration.buildFolderPath, declarationDir)); - } } // Run the linters to completion. Linters emit errors and warnings to the logger. @@ -166,8 +145,7 @@ export default class LintPlugin implements IHeftTaskPlugin { changedFiles, fix, sarifLogPath, - includeAdditionalFiles, - emitFolderPaths + includeAdditionalFiles }); } catch (error) { if (!(error instanceof AlreadyReportedError)) { @@ -256,8 +234,7 @@ export default class LintPlugin implements IHeftTaskPlugin { changedFiles, fix, sarifLogPath, - includeAdditionalFiles, - emitFolderPaths + includeAdditionalFiles } = options; // Ensure that we have initialized. This promise is cached, so calling init @@ -270,7 +247,6 @@ export default class LintPlugin implements IHeftTaskPlugin { tsProgram, fix, sarifLogPath, - emitFolderPaths, scopedLogger: taskSession.logger, linterToolPath: this.#eslintToolPath, linterConfigFilePath: this.#eslintConfigFilePath, diff --git a/heft-plugins/heft-typescript-plugin/src/TypeScriptPlugin.ts b/heft-plugins/heft-typescript-plugin/src/TypeScriptPlugin.ts index 6372a4b76a3..32dff7658e2 100644 --- a/heft-plugins/heft-typescript-plugin/src/TypeScriptPlugin.ts +++ b/heft-plugins/heft-typescript-plugin/src/TypeScriptPlugin.ts @@ -128,12 +128,6 @@ export interface IPartialTsconfig { export interface IChangedFilesHookOptions { program: TTypescript.Program; changedFiles?: ReadonlySet; - /** - * The absolute paths of the folders that the TypeScript compiler emits output to. This includes the - * `outDir` and `declarationDir` from the compiler options as well as any `additionalModuleKindsToEmit` - * output folders (for example `lib-esm`). Consumers can use these to avoid processing generated output. - */ - emitFolderPaths: ReadonlySet; } /** @@ -387,25 +381,7 @@ export default class TypeScriptPlugin implements IHeftTaskPlugin { ) => { // Provide the typescript program dependent plugins if (this.accessor.onChangedFilesHook.isUsed()) { - // Collect the folders that the compiler emits output to so that consumers can avoid processing - // generated output. `additionalModuleKindsToEmit` output folders (for example `lib-esm`) are not - // part of the compiler options, so they must be included from the Heft configuration. - const compilerOptions: TTypescript.CompilerOptions = program.getCompilerOptions(); - const emitFolderPaths: Set = new Set(); - const { outDir, declarationDir } = compilerOptions; - if (outDir) { - emitFolderPaths.add(path.resolve(heftConfiguration.buildFolderPath, outDir)); - } - - if (declarationDir) { - emitFolderPaths.add(path.resolve(heftConfiguration.buildFolderPath, declarationDir)); - } - - for (const { outFolderName } of typeScriptConfigurationJson?.additionalModuleKindsToEmit ?? []) { - emitFolderPaths.add(path.resolve(heftConfiguration.buildFolderPath, outFolderName)); - } - - this.accessor.onChangedFilesHook.call({ program, changedFiles, emitFolderPaths }); + this.accessor.onChangedFilesHook.call({ program, changedFiles }); } } }; diff --git a/libraries/package-extractor/eslint.config.js b/libraries/package-extractor/eslint.config.js index 87132f43292..7210e69761b 100644 --- a/libraries/package-extractor/eslint.config.js +++ b/libraries/package-extractor/eslint.config.js @@ -9,6 +9,10 @@ module.exports = [ ...nodeProfile, ...friendlyLocalsMixin, ...tsdocMixin, + // These folders contain test fixtures and generated output that are not source code. + { + ignores: ['test-output/**'] + }, { files: ['**/*.ts', '**/*.tsx'], languageOptions: { diff --git a/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/profile/_common.js b/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/profile/_common.js index 194a5fdbbc1..ceac5d3c0f6 100644 --- a/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/profile/_common.js +++ b/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/profile/_common.js @@ -59,6 +59,14 @@ const localTypeAwareRules = { module.exports = { localTypeAwareRules, localCommonConfig: [ + // Build output and other generated folders are not source code and should never be linted. (This is also + // globally ignored by newer versions of @rushstack/eslint-config; it is repeated here so that projects + // consuming the currently-published version via this rig also ignore them. Remove once the dependency is + // bumped.) These patterns are anchored to the project root, so a source folder such as "src/lib" is not + // affected. + { + ignores: ['lib/**', 'lib-*/**', 'dist/**', 'temp/**', 'coverage/**'] + }, { files: ['**/*.ts', '**/*.tsx'], plugins: { diff --git a/vscode-extensions/rush-vscode-extension/eslint.config.js b/vscode-extensions/rush-vscode-extension/eslint.config.js index eac79367926..1408a0c6c91 100644 --- a/vscode-extensions/rush-vscode-extension/eslint.config.js +++ b/vscode-extensions/rush-vscode-extension/eslint.config.js @@ -4,4 +4,11 @@ const nodeTrustedToolProfile = require('@rushstack/heft-vscode-extension-rig/profiles/default/includes/eslint/flat/profile/node-trusted-tool'); const friendlyLocalsMixin = require('@rushstack/heft-vscode-extension-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); -module.exports = [...nodeTrustedToolProfile, ...friendlyLocalsMixin]; +module.exports = [ + ...nodeTrustedToolProfile, + ...friendlyLocalsMixin, + // The webview folder contains generated webpack bundle output, not source code. + { + ignores: ['webview/**'] + } +]; From 866aef4364fef7be8bd2b25145311f5461dc21b0 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Mon, 21 Sep 2026 00:11:07 -0400 Subject: [PATCH 7/9] [heft-lint-plugin] Fix Windows path handling for program-file matching The additional-file feature resolved the TypeScript program file names with `path.resolve(buildFolderPath, ...)`, which produces backslash paths on Windows. LinterBase then compared those against `SourceFile.fileName` (which TypeScript always reports with forward slashes), so on Windows every program file missed the lookup and was skipped -- producing empty lint results for the program files (observed as an eslint-9-test SARIF snapshot mismatch on Windows). Normalize all file paths to forward slashes before comparing them: the resolved TypeScript program file names, the paths ESLint reports for enumerated additional files, and the project-folder prefix used to compute the ignore patterns. This is a no-op on POSIX and corrects the comparison on Windows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- heft-plugins/heft-lint-plugin/src/Eslint.ts | 33 +++++++++++-------- .../heft-lint-plugin/src/LintPlugin.ts | 10 ++++-- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/heft-plugins/heft-lint-plugin/src/Eslint.ts b/heft-plugins/heft-lint-plugin/src/Eslint.ts index d2c8f763744..43e5414f3aa 100644 --- a/heft-plugins/heft-lint-plugin/src/Eslint.ts +++ b/heft-plugins/heft-lint-plugin/src/Eslint.ts @@ -145,8 +145,13 @@ export class Eslint extends LinterBase path.resolve(buildFolderPath, filePath)) + tsProgram + .getRootFileNames() + .map((filePath: string) => Path.convertToSlashes(path.resolve(buildFolderPath, filePath))) ); // ESLint configuration paths are relative to the project folder. Compute the project-relative paths of the // files in the TypeScript program so that the injected program can be scoped to just those files, and so @@ -154,10 +159,10 @@ export class Eslint extends LinterBase { linter.printVersionHeader(); // Resolve the program's root file names against the project folder so that they can be compared against the - // absolute paths that ESLint reports for the files it selects. + // absolute paths that ESLint reports for the files it selects. Normalize to forward slashes so that the + // comparison works on Windows: TypeScript reports `SourceFile.fileName` with forward slashes on every + // platform, whereas `path.resolve` produces backslashes on Windows. const { buildFolderPath } = heftConfiguration; const typeScriptFilenames: Set = new Set( - tsProgram.getRootFileNames().map((filePath: string) => path.resolve(buildFolderPath, filePath)) + tsProgram + .getRootFileNames() + .map((filePath: string) => Path.convertToSlashes(path.resolve(buildFolderPath, filePath))) ); await linter.performLintingAsync({ tsProgram, From 006e79fd2974e602db27bbce35bfcb5d2cc55082 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Mon, 21 Sep 2026 16:09:27 -0400 Subject: [PATCH 8/9] [heft-lint-plugin] Address review: multi-program dedup and glob-safe patterns Fixes two issues found in review of the additional-file enumeration: - Multi-program double-processing: with project-reference / composite builds the lint hook receives more than one TypeScript program, but additional-file enumeration ran only for the first program and excluded only that program's files. Files belonging to other programs were then linted both as additional files (in the first pass) and as program files (in their own pass). Exclude the union of every program's root file names from the enumeration, and de-duplicate the linted files in LinterBase so a file that is both a program source file and an enumerated file is linted only once. - Glob metacharacters: exact program file paths were used directly as ESLint `files`/`ignores` patterns, so a file name containing characters such as `[ ] * ? { } ( )` would be interpreted as a glob. Escape those characters when building the patterns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- heft-plugins/heft-lint-plugin/src/Eslint.ts | 60 +++++++++++++------ .../heft-lint-plugin/src/LintPlugin.ts | 30 ++++++++-- .../heft-lint-plugin/src/LinterBase.ts | 33 ++++++++-- 3 files changed, 93 insertions(+), 30 deletions(-) diff --git a/heft-plugins/heft-lint-plugin/src/Eslint.ts b/heft-plugins/heft-lint-plugin/src/Eslint.ts index 43e5414f3aa..fc6fe168974 100644 --- a/heft-plugins/heft-lint-plugin/src/Eslint.ts +++ b/heft-plugins/heft-lint-plugin/src/Eslint.ts @@ -94,6 +94,32 @@ const ESLINT_LEGACY_CONFIG_FILENAMES: Set = new Set([ // lint that are not part of the TypeScript program. const MAX_ADDITIONAL_FILE_READ_CONCURRENCY: number = 10; +// ESLint interprets `files`/`ignores` entries as glob patterns (matched with minimatch), so characters that are +// significant to the matcher must be escaped when an exact file path is used as a pattern. Otherwise a file name +// such as `src/[id].ts` would be treated as a character class rather than a literal path. +const GLOB_METACHARACTER_REGEXP: RegExp = /[\\*?[\]{}()!+@|]/g; + +function escapeGlobPattern(filePath: string): string { + return filePath.replace(GLOB_METACHARACTER_REGEXP, '\\$&'); +} + +// Convert forward-slash absolute file paths into project-relative, glob-escaped patterns. Only files under the +// project folder can be expressed as ESLint configuration patterns. +function toProjectRelativeGlobPatterns( + filePaths: Iterable, + normalizedBuildFolderPath: string +): string[] { + const patterns: string[] = []; + for (const filePath of filePaths) { + if (Path.isUnder(filePath, normalizedBuildFolderPath)) { + // filePath is already a forward-slash absolute path under the project folder, so strip the prefix (plus + // the separator) instead of recomputing the relative path. + patterns.push(escapeGlobPattern(filePath.slice(normalizedBuildFolderPath.length + 1))); + } + } + return patterns; +} + export class Eslint extends LinterBase { readonly #eslintPackage: typeof TEslint | typeof TEslintLegacy; readonly #eslintPackageVersion: semver.SemVer; @@ -153,18 +179,13 @@ export class Eslint extends LinterBase Path.convertToSlashes(path.resolve(buildFolderPath, filePath))) ); - // ESLint configuration paths are relative to the project folder. Compute the project-relative paths of the - // files in the TypeScript program so that the injected program can be scoped to just those files, and so - // that those files can be excluded when enumerating the additional files to lint. Only files under the - // project folder can be expressed as ESLint configuration patterns. - const typeScriptFilePatterns: string[] = []; - for (const filePath of this.#typeScriptFilenames) { - if (Path.isUnder(filePath, normalizedBuildFolderPath)) { - // filePath is already a forward-slash absolute path under the project folder, so strip the prefix (plus - // the separator) instead of recomputing the relative path. - typeScriptFilePatterns.push(filePath.slice(normalizedBuildFolderPath.length + 1)); - } - } + // ESLint configuration paths are relative to the project folder. Compute the project-relative patterns of + // the files in the TypeScript program so that the injected program can be scoped to just those files, and so + // that those files can be excluded when enumerating the additional files to lint. + const typeScriptFilePatterns: string[] = toProjectRelativeGlobPatterns( + this.#typeScriptFilenames, + normalizedBuildFolderPath + ); let overrideConfig: TEslint.Linter.Config | TEslintLegacy.Linter.Config | undefined; let fixFn: Exclude; @@ -313,7 +334,7 @@ export class Eslint extends LinterBase + programFilenames: ReadonlySet ): Promise> { if (!this.#includeAdditionalFiles || !this.#fileEnumerator) { return []; @@ -324,15 +345,16 @@ export class Eslint extends LinterBase; includeAdditionalFiles: boolean; + /** + * The normalized (forward-slash absolute) root file names of every TypeScript program being linted in this + * run. Used to exclude program files from the enumerated additional files. + */ + allProgramFilenames: ReadonlySet; } function checkFix(taskSession: IHeftTaskSession, pluginOptions?: ILintPluginOptions): boolean { @@ -135,6 +140,17 @@ export default class LintPlugin implements IHeftTaskPlugin { } // Run the linters to completion. Linters emit errors and warnings to the logger. + // Compute the union of every program's root file names so that files linted as program files (by any + // program) are not also linted as enumerated additional files. This matters for project-reference / + // composite builds, where the lint hook receives more than one program. + const { buildFolderPath } = heftConfiguration; + const allProgramFilenames: Set = new Set(); + for (const [tsProgram] of typescriptChangedFiles) { + for (const rootFileName of tsProgram.getRootFileNames()) { + allProgramFilenames.add(Path.convertToSlashes(path.resolve(buildFolderPath, rootFileName))); + } + } + let includeAdditionalFiles: boolean = true; for (const [tsProgram, changedFiles] of typescriptChangedFiles) { try { @@ -145,7 +161,8 @@ export default class LintPlugin implements IHeftTaskPlugin { changedFiles, fix, sarifLogPath, - includeAdditionalFiles + includeAdditionalFiles, + allProgramFilenames }); } catch (error) { if (!(error instanceof AlreadyReportedError)) { @@ -234,7 +251,8 @@ export default class LintPlugin implements IHeftTaskPlugin { changedFiles, fix, sarifLogPath, - includeAdditionalFiles + includeAdditionalFiles, + allProgramFilenames } = options; // Ensure that we have initialized. This promise is cached, so calling init @@ -255,7 +273,7 @@ export default class LintPlugin implements IHeftTaskPlugin { includeAdditionalFiles }); lintOperations.push(() => - this.#runLinterAsync(eslintLinter, heftConfiguration, tsProgram, changedFiles) + this.#runLinterAsync(eslintLinter, heftConfiguration, tsProgram, changedFiles, allProgramFilenames) ); } @@ -270,7 +288,7 @@ export default class LintPlugin implements IHeftTaskPlugin { buildMetadataFolderPath: taskSession.tempFolderPath }); lintOperations.push(() => - this.#runLinterAsync(tslintLinter, heftConfiguration, tsProgram, changedFiles) + this.#runLinterAsync(tslintLinter, heftConfiguration, tsProgram, changedFiles, allProgramFilenames) ); } @@ -282,7 +300,8 @@ export default class LintPlugin implements IHeftTaskPlugin { linter: LinterBase, heftConfiguration: HeftConfiguration, tsProgram: IExtendedProgram, - changedFiles?: ReadonlySet | undefined + changedFiles: ReadonlySet | undefined, + allProgramFilenames: ReadonlySet ): Promise { linter.printVersionHeader(); @@ -299,6 +318,7 @@ export default class LintPlugin implements IHeftTaskPlugin { await linter.performLintingAsync({ tsProgram, typeScriptFilenames, + allProgramFilenames, changedFiles: changedFiles || new Set(tsProgram.getSourceFiles()) }); } diff --git a/heft-plugins/heft-lint-plugin/src/LinterBase.ts b/heft-plugins/heft-lint-plugin/src/LinterBase.ts index ca7c0d9159e..f7bafd0acaa 100644 --- a/heft-plugins/heft-lint-plugin/src/LinterBase.ts +++ b/heft-plugins/heft-lint-plugin/src/LinterBase.ts @@ -50,6 +50,14 @@ export interface IRunLinterOptions { */ typeScriptFilenames: Set; + /** + * The normalized (forward-slash absolute) file names of every TypeScript program being linted in this run, + * not just the current one. Files in this set are linted as program files, so they are excluded when + * enumerating additional files (which prevents a file from being linted twice when there are multiple + * programs). Defaults to {@link IRunLinterOptions.typeScriptFilenames} when there is a single program. + */ + allProgramFilenames: ReadonlySet; + /** * The set of files that TypeScript has compiled since the last compilation. */ @@ -106,10 +114,11 @@ export abstract class LinterBase { const commonDirectory: string = options.tsProgram.getCommonSourceDirectory(); - // Files to lint that are not part of the TypeScript program (subclasses may enumerate their own). The - // default implementation returns none. + // Files to lint that are not part of any TypeScript program (subclasses may enumerate their own). The + // default implementation returns none. The set of all program files is passed so that subclasses can + // exclude files that will be linted as program files (by any program) from the additional files. const extraSourceFiles: Iterable = await this.getExtraSourceFilesToLintAsync( - options.typeScriptFilenames + options.allProgramFilenames ); const relativePaths: Map = new Map(); @@ -200,13 +209,22 @@ export abstract class LinterBase { const changedFilePaths: Set = new Set( Array.from(options.changedFiles, (sourceFile: IExtendedSourceFile) => sourceFile.fileName) ); + // A file may appear both as a program source file and as an enumerated additional file (for example a + // program file that is not one of the program's root file names). Track the files that have been linted so + // that each file is linted at most once per invocation. + const lintedFilePaths: Set = new Set(); for (const sourceFile of sourceFiles) { const filePath: string = sourceFile.fileName; const relative: string | undefined = relativePaths.get(filePath); - if (relative === undefined || (await this.isFileExcludedAsync(filePath))) { + if ( + relative === undefined || + lintedFilePaths.has(filePath) || + (await this.isFileExcludedAsync(filePath)) + ) { continue; } + lintedFilePaths.add(filePath); const version: string = await this.getSourceFileHashAsync(sourceFile); const cachedVersion: string = cachedNoFailureFileVersions.get(relative) || ''; @@ -253,11 +271,14 @@ export abstract class LinterBase { } /** - * Returns files to lint that are not part of the TypeScript program. Subclasses may override this to + * Returns files to lint that are not part of any TypeScript program. Subclasses may override this to * enumerate additional files selected by the linter configuration. The default implementation returns none. + * + * @param programFilenames - the normalized file names of every TypeScript program being linted in this run; + * subclasses should exclude these so that program files are not also returned as additional files. */ protected async getExtraSourceFilesToLintAsync( - typeScriptFilenames: ReadonlySet + programFilenames: ReadonlySet ): Promise> { return []; } From c050eb7214fe3db5996aedea8f45a78338d7a05f Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Mon, 21 Sep 2026 17:53:11 -0400 Subject: [PATCH 9/9] [heft-lint-plugin] Make glob-pattern escaping minimatch-correct The previous escaping was not compatible with the minimatch version ESLint uses (3.1.5, with `{ dot: true, allowWindowsEscape: true }`): escaping `@`/`+` broke matching of names like `foo@(bar).ts`, and a leading `#` (comment) or `!` (negation) was not handled. Escape only the always-significant characters (`\ * ? [ ] { } ( )`) -- escaping the parentheses already neutralizes extglob prefixes -- and escape a leading `#`/`!`. Verified against ESLint's minimatch options that ordinary and pathological file names match exactly with no over-broadening. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- heft-plugins/heft-lint-plugin/src/Eslint.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/heft-plugins/heft-lint-plugin/src/Eslint.ts b/heft-plugins/heft-lint-plugin/src/Eslint.ts index fc6fe168974..23c6e6fc5c8 100644 --- a/heft-plugins/heft-lint-plugin/src/Eslint.ts +++ b/heft-plugins/heft-lint-plugin/src/Eslint.ts @@ -94,13 +94,22 @@ const ESLINT_LEGACY_CONFIG_FILENAMES: Set = new Set([ // lint that are not part of the TypeScript program. const MAX_ADDITIONAL_FILE_READ_CONCURRENCY: number = 10; -// ESLint interprets `files`/`ignores` entries as glob patterns (matched with minimatch), so characters that are -// significant to the matcher must be escaped when an exact file path is used as a pattern. Otherwise a file name -// such as `src/[id].ts` would be treated as a character class rather than a literal path. -const GLOB_METACHARACTER_REGEXP: RegExp = /[\\*?[\]{}()!+@|]/g; +// ESLint interprets `files`/`ignores` entries as glob patterns (matched with minimatch using +// `{ dot: true, allowWindowsEscape: true }`), so characters that are significant to the matcher must be escaped +// when an exact file path is used as a pattern. Otherwise a file name such as `src/[id].ts` would be treated as +// a character class rather than a literal path. Escaping the parentheses also neutralizes the extglob prefixes +// (`@(`, `+(`, `!(`, `?(`, `*(`), so those prefix characters do not need to be escaped -- and escaping `@`/`+` +// would actually break matching under the minimatch version ESLint uses. +const GLOB_METACHARACTER_REGEXP: RegExp = /[\\*?[\]{}()]/g; function escapeGlobPattern(filePath: string): string { - return filePath.replace(GLOB_METACHARACTER_REGEXP, '\\$&'); + const escaped: string = filePath.replace(GLOB_METACHARACTER_REGEXP, '\\$&'); + // A pattern beginning with `#` is treated as a comment, and one beginning with `!` as a negation, so escape a + // leading occurrence of either. + if (escaped.startsWith('#') || escaped.startsWith('!')) { + return `\\${escaped}`; + } + return escaped; } // Convert forward-slash absolute file paths into project-relative, glob-escaped patterns. Only files under the