From 25d46d91324da9204fbbb59a00effc9f08f812c8 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sat, 12 Sep 2026 22:50:14 -0400 Subject: [PATCH 1/6] [heft-lint-plugin] Lint files selected by ESLint flat config Use ESLint's native flat-config enumeration to find files outside the TypeScript program, then lint them through the existing cache and reporting pipeline. Exclude TypeScript emit folders and ESLint's built-in JavaScript extensions from the additional-file pass, partition SARIF metadata by ESLint instance, keep the TypeScript plugin as an accessor-only development dependency, and cover the behavior with the ESLint 9 SARIF fixture. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../.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 | 120 ++++++++++++++++-- .../heft-lint-plugin/src/LintPlugin.ts | 81 ++++++++++-- .../heft-lint-plugin/src/LinterBase.ts | 45 +++++-- 8 files changed, 286 insertions(+), 27 deletions(-) create mode 100644 build-tests/eslint-9-test/src/non-program.custom create mode 100644 common/changes/@rushstack/heft-lint-plugin/eslint-flat-config-files_2026-09-01-12-00-00.json diff --git a/build-tests/eslint-9-test/.eslint-bulk-suppressions.json b/build-tests/eslint-9-test/.eslint-bulk-suppressions.json index 961e6033858..467159daa95 100644 --- a/build-tests/eslint-9-test/.eslint-bulk-suppressions.json +++ b/build-tests/eslint-9-test/.eslint-bulk-suppressions.json @@ -4,6 +4,11 @@ "file": "src/index.ts", "scopeId": ".", "rule": "@typescript-eslint/naming-convention" + }, + { + "file": "src/non-program.custom", + "scopeId": ".", + "rule": "no-undef" } ] } diff --git a/build-tests/eslint-9-test/eslint.config.js b/build-tests/eslint-9-test/eslint.config.js index 75eb0c727fc..ca63423a8d6 100644 --- a/build-tests/eslint-9-test/eslint.config.js +++ b/build-tests/eslint-9-test/eslint.config.js @@ -7,6 +7,9 @@ const nodeTrustedToolProfile = require('local-node-rig/profiles/default/includes const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); module.exports = [ + { + ignores: ['coverage/**'] + }, ...nodeTrustedToolProfile, ...friendlyLocalsMixin, { @@ -25,5 +28,11 @@ module.exports = [ tsconfigRootDir: __dirname } } + }, + { + files: ['**/*.custom'], + rules: { + 'no-undef': 'warn' + } } ]; diff --git a/build-tests/eslint-9-test/src/__snapshots__/sarif.test.ts.snap b/build-tests/eslint-9-test/src/__snapshots__/sarif.test.ts.snap index 0ddfa4d6a6f..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..5007eaaf88c 100644 --- a/heft-plugins/heft-lint-plugin/src/Eslint.ts +++ b/heft-plugins/heft-lint-plugin/src/Eslint.ts @@ -11,10 +11,10 @@ import type * as TEslintLegacy from 'eslint-8'; import * as semver from 'semver'; import stableStringify from 'json-stable-stringify-without-jsonify'; -import { FileError, FileSystem } from '@rushstack/node-core-library'; +import { FileError, FileSystem, Path } from '@rushstack/node-core-library'; import type { HeftConfiguration } from '@rushstack/heft'; -import { LinterBase, type ILinterBaseOptions } from './LinterBase'; +import { LinterBase, type IAdditionalLintFile, type ILinterBaseOptions } from './LinterBase'; import type { IExtendedSourceFile } from './internalTypings/TypeScriptInternals'; import { name as pluginName, version as pluginVersion } from '../package.json'; @@ -81,11 +81,16 @@ 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 { +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 #additionalFilesLinter: TEslint.ESLint | undefined; readonly #eslintTimings: Map = new Map(); readonly #currentFixMessages: (TEslint.Linter.LintMessage | TEslintLegacy.Linter.LintMessage)[] = []; @@ -93,8 +98,12 @@ export class Eslint extends LinterBase = new Map(); + readonly #additionalLintResults: Set = + new Set(); 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(filePath)) + ); + if (this.#eslintPackageVersion.major >= 9) { + const typeScriptIgnorePatterns: string[] = Array.from(this.#typeScriptFilenames, (filePath: string) => + Path.convertToSlashes(path.relative(buildFolderPath, filePath)) + ).filter((relativePath: string) => relativePath !== '..' && !relativePath.startsWith('../')); + const flatEslintPackage: typeof TEslint = eslintPackage as typeof TEslint; + this.#additionalFilesLinter = new flatEslintPackage.ESLint({ + cwd: buildFolderPath, + overrideConfigFile: linterConfigFilePath, + fix: fixFn + }); + this.#fileEnumerator = new flatEslintPackage.ESLint({ + cwd: buildFolderPath, + errorOnUnmatchedPattern: false, + overrideConfigFile: linterConfigFilePath, + overrideConfig: { + name: `${pluginName}/ignore-typescript-program-files`, + ignores: [...typeScriptIgnorePatterns, ...(additionalFileIgnorePatterns || [])] + }, + ruleFilter: () => false + }); + } + this.#eslintTimings = eslintTimings; } @@ -250,12 +285,45 @@ export class Eslint extends LinterBase> { + if (!this.#fileEnumerator) { + return new Set(); + } + + const lintResults: TEslint.ESLint.LintResult[] = await this.#fileEnumerator.lintFiles(['.']); + lintResults.sort((left: TEslint.ESLint.LintResult, right: TEslint.ESLint.LintResult) => + left.filePath.localeCompare(right.filePath) + ); + + const additionalLintFiles: IAdditionalLintFile[] = await Promise.all( + lintResults + .filter( + (lintResult: TEslint.ESLint.LintResult) => + !this.#typeScriptFilenames.has(path.resolve(lintResult.filePath)) && + !ESLINT_DEFAULT_EXTENSIONS.has(path.extname(lintResult.filePath)) + ) + .map(async (lintResult: TEslint.ESLint.LintResult): Promise => { + return { + kind: 'additional', + fileName: lintResult.filePath, + text: await FileSystem.readFileAsync(lintResult.filePath), + version: '' + }; + }) + ); + + return new Set(additionalLintFiles); + } + protected override async getCacheVersionAsync(): Promise { return `${this.#eslintPackageVersion.version}_${process.version}`; } - protected override async getSourceFileHashAsync(sourceFile: IExtendedSourceFile): Promise { - const sourceFileEslintConfiguration: TEslint.Linter.Config = await this.#linter.calculateConfigForFile( + protected override async getSourceFileHashAsync( + sourceFile: IExtendedSourceFile | IAdditionalLintFile + ): Promise { + const linter: TEslint.ESLint | TEslintLegacy.ESLint = this.#getLinterForSourceFile(sourceFile); + const sourceFileEslintConfiguration: TEslint.Linter.Config = await linter.calculateConfigForFile( sourceFile.fileName ); @@ -272,10 +340,11 @@ export class Eslint extends LinterBase { + const linter: TEslint.ESLint | TEslintLegacy.ESLint = this.#getLinterForSourceFile(sourceFile); const lintResults: TEslint.ESLint.LintResult[] | TEslintLegacy.ESLint.LintResult[] = - await this.#linter.lintText(sourceFile.text, { filePath: sourceFile.fileName }); + await linter.lintText(sourceFile.text, { filePath: sourceFile.fileName }); // Map the fix messages to the results. This API should only return one result per file, so we can be sure // that the fix messages belong to the returned result. If we somehow receive multiple results, we will @@ -286,6 +355,12 @@ export class Eslint extends LinterBase { @@ -349,8 +424,19 @@ 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,26 @@ 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 linters: LinterBase[] = []; + const additionalFilesByLinter: Map< + LinterBase, + ReadonlySet + > = new Map(); if (this.#eslintConfigFilePath && this.#eslintToolPath) { const eslintLinter: Eslint = await Eslint.initializeAsync({ tsProgram, @@ -238,8 +264,13 @@ export default class LintPlugin implements IHeftTaskPlugin { linterToolPath: this.#eslintToolPath, linterConfigFilePath: this.#eslintConfigFilePath, buildFolderPath: heftConfiguration.buildFolderPath, - buildMetadataFolderPath: taskSession.tempFolderPath + buildMetadataFolderPath: taskSession.tempFolderPath, + additionalFileIgnorePatterns }); + if (includeAdditionalFiles) { + additionalFilesByLinter.set(eslintLinter, await eslintLinter.getAdditionalLintFilesAsync()); + } + linters.push(eslintLinter); } @@ -257,13 +288,18 @@ export default class LintPlugin implements IHeftTaskPlugin { } // 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( + linters.map((linter) => + this.#runLinterAsync(linter, tsProgram, changedFiles, additionalFilesByLinter.get(linter)) + ) + ); } async #runLinterAsync( - linter: LinterBase, + linter: LinterBase, tsProgram: IExtendedProgram, - changedFiles?: ReadonlySet | undefined + changedFiles?: ReadonlySet | undefined, + additionalFiles?: ReadonlySet | undefined ): Promise { linter.printVersionHeader(); @@ -271,7 +307,34 @@ 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 outputFolderPaths: Set = new Set(); + for (const tsProgram of tsPrograms) { + const { outDir, declarationDir } = tsProgram.getCompilerOptions(); + if (outDir) { + outputFolderPaths.add(outDir); + } + + if (declarationDir) { + outputFolderPaths.add(declarationDir); + } + } + + const { buildFolderPath } = heftConfiguration; + return Array.from(outputFolderPaths, (outputFolderPath: string) => { + const relativePath: string = path.relative(buildFolderPath, outputFolderPath).replaceAll('\\', '/'); + return `${relativePath}/**`; + }).filter( + (relativePath: string) => + relativePath !== '/**' && relativePath !== '../**' && !relativePath.startsWith('../') + ); + } } diff --git a/heft-plugins/heft-lint-plugin/src/LinterBase.ts b/heft-plugins/heft-lint-plugin/src/LinterBase.ts index 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 c58560ee30726421eca52cb187b3b06988971e46 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sun, 13 Sep 2026 00:00:43 -0400 Subject: [PATCH 2/6] [heft-lint-plugin] Lint additional files with a single ESLint instance Scope the injected TypeScript program to the program's files so that a single ESLint instance lints both program files (using the in-memory program) and the additional files selected by the ESLint configuration (using the configuration's own parser), removing the separate additional-files linter. 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. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- heft-plugins/heft-lint-plugin/src/Eslint.ts | 109 ++++++++++-------- .../heft-lint-plugin/src/LintPlugin.ts | 29 ++--- 2 files changed, 69 insertions(+), 69 deletions(-) diff --git a/heft-plugins/heft-lint-plugin/src/Eslint.ts b/heft-plugins/heft-lint-plugin/src/Eslint.ts index 5007eaaf88c..b0e599c39eb 100644 --- a/heft-plugins/heft-lint-plugin/src/Eslint.ts +++ b/heft-plugins/heft-lint-plugin/src/Eslint.ts @@ -90,16 +90,12 @@ export class Eslint extends LinterBase< readonly #eslintPackage: typeof TEslint | typeof TEslintLegacy; readonly #eslintPackageVersion: semver.SemVer; readonly #linter: TEslint.ESLint | TEslintLegacy.ESLint; - readonly #additionalFilesLinter: TEslint.ESLint | undefined; 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 #additionalLintResults: Set = - new Set(); readonly #sarifLogPath: string | undefined; readonly #configHashMap: WeakMap = new WeakMap(); readonly #fileEnumerator: TEslint.ESLint | undefined; @@ -139,6 +135,16 @@ export class Eslint extends LinterBase< this.#sarifLogPath = sarifLogPath; + this.#typeScriptFilenames = new Set( + tsProgram.getRootFileNames().map((filePath: string) => path.resolve(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. + const typeScriptFilePatterns: string[] = Array.from(this.#typeScriptFilenames, (filePath: string) => + Path.convertToSlashes(path.relative(buildFolderPath, filePath)) + ).filter((relativePath: string) => relativePath !== '..' && !relativePath.startsWith('../')); + let overrideConfig: TEslint.Linter.Config | TEslintLegacy.Linter.Config | undefined; let fixFn: Exclude; if (fix) { @@ -188,7 +194,12 @@ export class Eslint extends LinterBase< // fix fully succeeds. This conflicts with providing an existing program as the code no longer maps to // the provided program, producing garbage fix output. To avoid this, only provide the existing program // if we're not fixing. + // Scope the injected TypeScript program to the files that the program actually contains. Files that are + // selected by the ESLint configuration but excluded from the program (for example config files or tests + // outside the tsconfig) will fall through to the ESLint configuration's own parser instead of failing to + // resolve against the program. const eslintOverrideConfig: TEslint.Linter.Config = { + files: typeScriptFilePatterns, languageOptions: { parserOptions: overrideParserOptions } @@ -205,26 +216,17 @@ export class Eslint extends LinterBase< // eslint-disable-next-line @typescript-eslint/no-explicit-any fix: fixFn as any }); - this.#typeScriptFilenames = new Set( - tsProgram.getRootFileNames().map((filePath: string) => path.resolve(filePath)) - ); if (this.#eslintPackageVersion.major >= 9) { - const typeScriptIgnorePatterns: string[] = Array.from(this.#typeScriptFilenames, (filePath: string) => - Path.convertToSlashes(path.relative(buildFolderPath, filePath)) - ).filter((relativePath: string) => relativePath !== '..' && !relativePath.startsWith('../')); const flatEslintPackage: typeof TEslint = eslintPackage as typeof TEslint; - this.#additionalFilesLinter = new flatEslintPackage.ESLint({ - cwd: buildFolderPath, - overrideConfigFile: linterConfigFilePath, - fix: fixFn - }); + // 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: { name: `${pluginName}/ignore-typescript-program-files`, - ignores: [...typeScriptIgnorePatterns, ...(additionalFileIgnorePatterns || [])] + ignores: [...typeScriptFilePatterns, ...(additionalFileIgnorePatterns || [])] }, ruleFilter: () => false }); @@ -322,8 +324,7 @@ export class Eslint extends LinterBase< protected override async getSourceFileHashAsync( sourceFile: IExtendedSourceFile | IAdditionalLintFile ): Promise { - const linter: TEslint.ESLint | TEslintLegacy.ESLint = this.#getLinterForSourceFile(sourceFile); - const sourceFileEslintConfiguration: TEslint.Linter.Config = await linter.calculateConfigForFile( + const sourceFileEslintConfiguration: TEslint.Linter.Config = await this.#linter.calculateConfigForFile( sourceFile.fileName ); @@ -342,9 +343,8 @@ export class Eslint extends LinterBase< protected override async lintFileAsync( sourceFile: TTypescript.SourceFile | IAdditionalLintFile ): Promise { - const linter: TEslint.ESLint | TEslintLegacy.ESLint = this.#getLinterForSourceFile(sourceFile); const lintResults: TEslint.ESLint.LintResult[] | TEslintLegacy.ESLint.LintResult[] = - await linter.lintText(sourceFile.text, { filePath: sourceFile.fileName }); + await this.#linter.lintText(sourceFile.text, { filePath: sourceFile.fileName }); // Map the fix messages to the results. This API should only return one result per file, so we can be sure // that the fix messages belong to the returned result. If we somehow receive multiple results, we will @@ -355,12 +355,6 @@ export class Eslint extends LinterBase< this.#fixMessagesByResult.set(lintResults[0], fixMessages); } - if (linter === this.#additionalFilesLinter) { - for (const lintResult of lintResults) { - this.#additionalLintResults.add(lintResult); - } - } - this._fixesPossible ||= !this._fix && lintResults.some((lintResult: TEslint.ESLint.LintResult | TEslintLegacy.ESLint.LintResult) => { @@ -407,7 +401,13 @@ export class Eslint extends LinterBase< // Report linter errors and warnings to the logger for (const lintMessage of lintResult.messages) { - const errorObject: FileError = this.#getLintFileError(lintResult, lintMessage); + const additionalFileTypeInformationError: string | undefined = + this.#getAdditionalFileTypeInformationError(lintResult, lintMessage); + const errorObject: FileError = this.#getLintFileError( + lintResult, + lintMessage, + additionalFileTypeInformationError + ); switch (lintMessage.severity) { case EslintMessageSeverity.error: { this._scopedLogger.emitError(errorObject); @@ -424,19 +424,8 @@ export class Eslint extends LinterBase< const sarifLogPath: string | undefined = this.#sarifLogPath; if (sarifLogPath) { - const primaryLintResults: TEslint.ESLint.LintResult[] = []; - const additionalLintResults: TEslint.ESLint.LintResult[] = []; - for (const lintResult of lintResults) { - const resultSet: TEslint.ESLint.LintResult[] = this.#additionalLintResults.has(lintResult) - ? additionalLintResults - : primaryLintResults; - resultSet.push(lintResult); - } - - const rulesMeta: TEslint.ESLint.LintResultData['rulesMeta'] = { - ...this.#linter.getRulesMetaForResults(primaryLintResults), - ...this.#additionalFilesLinter?.getRulesMetaForResults(additionalLintResults) - }; + const rulesMeta: TEslint.ESLint.LintResultData['rulesMeta'] = + this.#linter.getRulesMetaForResults(lintResults); const { formatEslintResultsAsSARIF } = await import('./SarifFormatter'); const sarifString: string = JSON.stringify( formatEslintResultsAsSARIF(lintResults, rulesMeta, { @@ -466,18 +455,38 @@ export class Eslint extends LinterBase< }); } - #getLinterForSourceFile( - sourceFile: TTypescript.SourceFile | IAdditionalLintFile - ): TEslint.ESLint | TEslintLegacy.ESLint { - if (sourceFile.kind === 'additional') { - if (!this.#additionalFilesLinter) { - throw new Error('The ESLint instance for additional files has not been initialized.'); - } + #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(path.resolve(lintResult.filePath))) { + return undefined; + } - return this.#additionalFilesLinter; + 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; } - return this.#linter; + 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( diff --git a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts index 8cb709d8714..2db4bb55d80 100644 --- a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts +++ b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts @@ -250,11 +250,7 @@ export default class LintPlugin implements IHeftTaskPlugin { // multiple times will only init once. await this.#ensureInitializedAsync(taskSession, heftConfiguration); - const linters: LinterBase[] = []; - const additionalFilesByLinter: Map< - LinterBase, - ReadonlySet - > = new Map(); + const lintOperations: (() => Promise)[] = []; if (this.#eslintConfigFilePath && this.#eslintToolPath) { const eslintLinter: Eslint = await Eslint.initializeAsync({ tsProgram, @@ -267,11 +263,10 @@ export default class LintPlugin implements IHeftTaskPlugin { buildMetadataFolderPath: taskSession.tempFolderPath, additionalFileIgnorePatterns }); - if (includeAdditionalFiles) { - additionalFilesByLinter.set(eslintLinter, await eslintLinter.getAdditionalLintFilesAsync()); - } - - 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) { @@ -284,22 +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, additionalFilesByLinter.get(linter)) - ) - ); + await Promise.all(lintOperations.map((lintOperation) => lintOperation())); } - async #runLinterAsync( - linter: LinterBase, + async #runLinterAsync( + linter: LinterBase, tsProgram: IExtendedProgram, changedFiles?: ReadonlySet | undefined, - additionalFiles?: ReadonlySet | undefined + additionalFiles?: ReadonlySet | undefined ): Promise { linter.printVersionHeader(); From 440a2c035cfee37f83df885be2f7a28ae7e72a38 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sun, 13 Sep 2026 00:05:57 -0400 Subject: [PATCH 3/6] [heft-lint-plugin] Address review feedback on additional-file linting Consolidate the TypeScript program pattern computation into a single loop using Path.isUnder, replace localeCompare with comparison operators, read the additional files with a bounded Async.forEachAsync, drop redundant path resolution of ESLint's already-absolute paths, use Path.convertToSlashes, and clarify the enumerator configuration comment. 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 | 4 +- 2 files changed, 54 insertions(+), 26 deletions(-) diff --git a/heft-plugins/heft-lint-plugin/src/Eslint.ts b/heft-plugins/heft-lint-plugin/src/Eslint.ts index b0e599c39eb..13353880a81 100644 --- a/heft-plugins/heft-lint-plugin/src/Eslint.ts +++ b/heft-plugins/heft-lint-plugin/src/Eslint.ts @@ -11,7 +11,7 @@ import type * as TEslintLegacy from 'eslint-8'; import * as semver from 'semver'; import stableStringify from 'json-stable-stringify-without-jsonify'; -import { FileError, FileSystem, Path } from '@rushstack/node-core-library'; +import { Async, FileError, FileSystem, Path } from '@rushstack/node-core-library'; import type { HeftConfiguration } from '@rushstack/heft'; import { LinterBase, type IAdditionalLintFile, type ILinterBaseOptions } from './LinterBase'; @@ -83,6 +83,10 @@ const ESLINT_LEGACY_CONFIG_FILENAMES: Set = new Set([ ]); 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 @@ -140,10 +144,14 @@ export class Eslint extends LinterBase< ); // 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. - const typeScriptFilePatterns: string[] = Array.from(this.#typeScriptFilenames, (filePath: string) => - Path.convertToSlashes(path.relative(buildFolderPath, filePath)) - ).filter((relativePath: string) => relativePath !== '..' && !relativePath.startsWith('../')); + // 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)) { + typeScriptFilePatterns.push(Path.convertToSlashes(path.relative(buildFolderPath, filePath))); + } + } let overrideConfig: TEslint.Linter.Config | TEslintLegacy.Linter.Config | undefined; let fixFn: Exclude; @@ -225,6 +233,9 @@ export class Eslint extends LinterBase< 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 || [])] }, @@ -293,25 +304,42 @@ export class Eslint extends LinterBase< } const lintResults: TEslint.ESLint.LintResult[] = await this.#fileEnumerator.lintFiles(['.']); - lintResults.sort((left: TEslint.ESLint.LintResult, right: TEslint.ESLint.LintResult) => - left.filePath.localeCompare(right.filePath) - ); - const additionalLintFiles: IAdditionalLintFile[] = await Promise.all( - lintResults - .filter( - (lintResult: TEslint.ESLint.LintResult) => - !this.#typeScriptFilenames.has(path.resolve(lintResult.filePath)) && - !ESLINT_DEFAULT_EXTENSIONS.has(path.extname(lintResult.filePath)) - ) - .map(async (lintResult: TEslint.ESLint.LintResult): Promise => { - return { - kind: 'additional', - fileName: lintResult.filePath, - text: await FileSystem.readFileAsync(lintResult.filePath), - version: '' - }; - }) + // 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); @@ -463,7 +491,7 @@ export class Eslint extends LinterBase< // 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(path.resolve(lintResult.filePath))) { + if (!lintMessage.fatal || this.#typeScriptFilenames.has(lintResult.filePath)) { return undefined; } diff --git a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts index 2db4bb55d80..7ac30fa1f49 100644 --- a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts +++ b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts @@ -17,7 +17,7 @@ import type { IChangedFilesHookOptions, ITypeScriptPluginAccessor } from '@rushstack/heft-typescript-plugin'; -import { AlreadyReportedError } from '@rushstack/node-core-library'; +import { AlreadyReportedError, Path } from '@rushstack/node-core-library'; import type { IAdditionalLintFile, LinterBase } from './LinterBase'; import { Eslint } from './Eslint'; @@ -321,7 +321,7 @@ export default class LintPlugin implements IHeftTaskPlugin { const { buildFolderPath } = heftConfiguration; return Array.from(outputFolderPaths, (outputFolderPath: string) => { - const relativePath: string = path.relative(buildFolderPath, outputFolderPath).replaceAll('\\', '/'); + const relativePath: string = Path.convertToSlashes(path.relative(buildFolderPath, outputFolderPath)); return `${relativePath}/**`; }).filter( (relativePath: string) => From b3e6d537b9b92f03209b717bdc6e2cb6f754fcbf Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sun, 13 Sep 2026 00:38:33 -0400 Subject: [PATCH 4/6] [eslint-config] Separate type-aware rules and add without-type-information helper Group the profile's type-aware rules (naming-convention, no-floating-promises, no-for-in-array) into an explicit typeAwareRules set alongside the derived nonTypeAwareRules, and add a reusable flat/without-type-information helper that disables type-aware parsing and rules for a given set of files. Apply the same split to the repo's localCommonConfig and wire the helper through the node rigs, then use it in @rushstack/playwright-browser-tunnel to lint the Playwright config and test files (which are not part of the TypeScript program) with only the non-type-aware rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../eslint.config.js | 8 +- ...flat-config-files_2026-09-12-04-00-00.json | 9 ++ eslint/eslint-config/flat/profile/_common.js | 81 +++++++++++------ .../flat/without-type-information.js | 50 +++++++++++ eslint/local-eslint-config/.gitignore | 3 +- .../includes/eslint/flat/profile/_common.js | 90 ++++++++++--------- .../eslint/flat/without-type-information.js | 23 +++++ .../eslint/flat/without-type-information.js | 4 + 8 files changed, 198 insertions(+), 70 deletions(-) create mode 100644 common/changes/@rushstack/eslint-config/heft-lint-flat-config-files_2026-09-12-04-00-00.json create mode 100644 eslint/eslint-config/flat/without-type-information.js 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/eslint-config/heft-lint-flat-config-files_2026-09-12-04-00-00.json b/common/changes/@rushstack/eslint-config/heft-lint-flat-config-files_2026-09-12-04-00-00.json new file mode 100644 index 00000000000..f4dac8b2a35 --- /dev/null +++ b/common/changes/@rushstack/eslint-config/heft-lint-flat-config-files_2026-09-12-04-00-00.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/eslint-config", + "comment": "Group the type-aware rules separately (exposed as `typeAwareRules`/`nonTypeAwareRules`) and add a `flat/without-type-information` helper for linting files that are not part of the TypeScript program with only the non-type-aware rules.", + "type": "minor" + } + ] +} diff --git a/eslint/eslint-config/flat/profile/_common.js b/eslint/eslint-config/flat/profile/_common.js index 4513f97c146..44ad99fb0d9 100644 --- a/eslint/eslint-config/flat/profile/_common.js +++ b/eslint/eslint-config/flat/profile/_common.js @@ -183,6 +183,41 @@ const commonNamingConventionSelectors = [ } ]; +// These are the only rules in this profile that 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 +// (for example config files or tests that are not included by tsconfig.json) can be linted with only the +// non-type-aware rules. See the "without-type-information" helper (flat/without-type-information.js), which +// disables these rules and type-aware parsing for a given set of files. +const typeAwareRules = { + // NOTE: This new rule replaces several deprecated rules from @typescript-eslint/eslint-plugin@2.3.3: + // + // - @typescript-eslint/camelcase + // - @typescript-eslint/class-name-casing + // - @typescript-eslint/interface-name-prefix + // - @typescript-eslint/member-naming + // + // 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) + ], + + // RATIONALE: The #1 rule of promises is that every promise chain must be terminated by a catch() + // handler. Thus wherever a Promise arises, the code must either append a catch handler, + // or else return the object to a caller (who assumes this responsibility). Unterminated + // promise chains are a serious issue. Besides causing errors to be silently ignored, + // they can also cause a NodeJS process to terminate unexpectedly. + '@typescript-eslint/no-floating-promises': [ + 'error', + { + checkThenables: true + } + ], + + // RATIONALE: Catches a common coding mistake. + '@typescript-eslint/no-for-in-array': 'error' +}; + const commonConfig = [ // Manually authored .d.ts files are generally used to describe external APIs that are not expected // to follow our coding conventions. Linting those files tends to produce a lot of spurious suppressions, @@ -291,18 +326,9 @@ const commonConfig = [ } ], - // NOTE: This new rule replaces several deprecated rules from @typescript-eslint/eslint-plugin@2.3.3: - // - // - @typescript-eslint/camelcase - // - @typescript-eslint/class-name-casing - // - @typescript-eslint/interface-name-prefix - // - @typescript-eslint/member-naming - // - // 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) - ], + // Type-aware rules (require the TypeScript program) are grouped in typeAwareRules so that files outside + // the TypeScript program can be linted with only the non-type-aware rules. + ...typeAwareRules, // STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json '@typescript-eslint/no-array-constructor': 'warn', @@ -315,21 +341,6 @@ const commonConfig = [ // may be more appropriate such as "unknown", "{}", or "Record". '@typescript-eslint/no-explicit-any': 'warn', - // RATIONALE: The #1 rule of promises is that every promise chain must be terminated by a catch() - // handler. Thus wherever a Promise arises, the code must either append a catch handler, - // or else return the object to a caller (who assumes this responsibility). Unterminated - // promise chains are a serious issue. Besides causing errors to be silently ignored, - // they can also cause a NodeJS process to terminate unexpectedly. - '@typescript-eslint/no-floating-promises': [ - 'error', - { - checkThenables: true - } - ], - - // RATIONALE: Catches a common coding mistake. - '@typescript-eslint/no-for-in-array': 'error', - // STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json '@typescript-eslint/no-misused-new': 'error', @@ -774,4 +785,18 @@ const commonConfig = [ } ]; -module.exports = { commonNamingConventionSelectors, commonConfig }; +// Derive the non-type-aware rule group from the authored TypeScript source-file config so that it stays in sync +// with the rules above. Everything that is not part of typeAwareRules can be applied to files that lack type +// information. +const typeScriptSourceFileConfig = commonConfig.find( + (configObject) => + Array.isArray(configObject.files) && configObject.files.includes('**/*.ts') && configObject.rules +); +const nonTypeAwareRules = {}; +for (const [ruleName, ruleValue] of Object.entries(typeScriptSourceFileConfig.rules)) { + if (!(ruleName in typeAwareRules)) { + nonTypeAwareRules[ruleName] = ruleValue; + } +} + +module.exports = { commonNamingConventionSelectors, commonConfig, typeAwareRules, nonTypeAwareRules }; diff --git a/eslint/eslint-config/flat/without-type-information.js b/eslint/eslint-config/flat/without-type-information.js new file mode 100644 index 00000000000..5f51917a2fb --- /dev/null +++ b/eslint/eslint-config/flat/without-type-information.js @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const { typeAwareRules } = require('./profile/_common'); + +// Returns ESLint flat-config objects that lint the specified files WITHOUT type information: type-aware parsing +// is disabled and the profile's type-aware rules are turned off, leaving only the non-type-aware rules in effect. +// +// Use this for TypeScript files that are selected by your ESLint configuration but are NOT part of the project's +// TypeScript program -- for example configuration files or tests that are not included by tsconfig.json. Without +// this, typescript-eslint reports a fatal parsing error because it cannot associate those files with the project, +// and any type-aware rule would be unable to run. +// +// If your ESLint configuration layers additional type-aware rules on top of this profile, pass their rule names +// via "additionalTypeAwareRuleNames" so that they are disabled as well. +// +// IMPORTANT: These config objects must be included in your ESLint configuration AFTER the profile, so that they +// override the profile's type-aware parser options and rules for the specified files. +// +// Example (eslint.config.js): +// +// const { withoutTypeInformation } = require('@rushstack/eslint-config/flat/without-type-information'); +// +// module.exports = [ +// ...nodeTrustedToolProfile, +// ...withoutTypeInformation({ files: ['tests/**/*.ts', 'playwright.config.ts'] }) +// ]; +function withoutTypeInformation({ files, additionalTypeAwareRuleNames = [] }) { + const disabledTypeAwareRules = {}; + for (const ruleName of [...Object.keys(typeAwareRules), ...additionalTypeAwareRuleNames]) { + disabledTypeAwareRules[ruleName] = 'off'; + } + + return [ + { + files, + languageOptions: { + parserOptions: { + // Disable type-aware parsing so that files outside the TypeScript program do not fail to resolve + // against it. + project: false, + projectService: false + } + }, + rules: disabledTypeAwareRules + } + ]; +} + +module.exports = { withoutTypeInformation }; diff --git a/eslint/local-eslint-config/.gitignore b/eslint/local-eslint-config/.gitignore index 281714b6678..229cd2b88bf 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 \ No newline at end of file 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..44d45c284e9 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,6 +11,51 @@ 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 = { localCommonConfig: [ { @@ -42,15 +87,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 +149,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'], @@ -215,5 +224,6 @@ module.exports = { 'import/no-duplicates': 'off' } } - ] + ], + localTypeAwareRules }; 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..ae0befc2299 --- /dev/null +++ b/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/without-type-information.js @@ -0,0 +1,23 @@ +// 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 }) { + return baseWithoutTypeInformation({ + files, + additionalTypeAwareRuleNames: Object.keys(localTypeAwareRules) + }); +} + +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 2f28a5b68afd7c392ecf96fe72e25ad6ba03e9d1 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sun, 13 Sep 2026 00:47:31 -0400 Subject: [PATCH 5/6] [playwright-browser-tunnel] Fix lint issues surfaced in non-program files Now that the Playwright config and test files are linted (with non-type-aware rules), fix the issues they surface: add the license header to playwright.config.ts, and in tests/testFixture.ts correct the import to the exported createTunneledBrowserAsync API, add the missing type annotations, order the imports, and use an allowed console method. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/playwright-browser-tunnel/playwright.config.ts | 3 +++ apps/playwright-browser-tunnel/tests/testFixture.ts | 12 ++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) 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 From 73af4b723b9b3efefbaf512ecb04407e9b03af2f Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Sun, 13 Sep 2026 02:25:33 -0400 Subject: [PATCH 6/6] Address PR review feedback - heft-lint-plugin: resolve TypeScript program root file names against the project folder (not the process cwd), and derive the project-relative ESLint patterns by slicing the project-folder prefix instead of using path.relative. - @rushstack/eslint-config: drop the derived nonTypeAwareRules export/loop and keep only the explicit typeAwareRules group; simplify the without-type-information helper to an explicit disabled-rules list. - Defer the decoupled-local-node-rig / node-rig wiring to a follow-up PR (to be done after @rushstack/eslint-config is published and the dependency is bumped); fix @rushstack/playwright-browser-tunnel inline in its own eslint.config.js for now. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../eslint.config.js | 25 ++++-- ...flat-config-files_2026-09-12-04-00-00.json | 2 +- eslint/eslint-config/flat/profile/_common.js | 16 +--- .../flat/without-type-information.js | 18 ++-- eslint/local-eslint-config/.gitignore | 3 +- heft-plugins/heft-lint-plugin/src/Eslint.ts | 6 +- .../heft-lint-plugin/src/LintPlugin.ts | 24 ++--- .../includes/eslint/flat/profile/_common.js | 90 +++++++++---------- .../eslint/flat/without-type-information.js | 23 ----- .../eslint/flat/without-type-information.js | 4 - 10 files changed, 88 insertions(+), 123 deletions(-) delete mode 100644 rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/without-type-information.js delete 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 d30a5ca7bcc..00e4e13f533 100644 --- a/apps/playwright-browser-tunnel/eslint.config.js +++ b/apps/playwright-browser-tunnel/eslint.config.js @@ -3,9 +3,6 @@ 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, @@ -18,7 +15,23 @@ module.exports = [ } } }, - // 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'] }) + { + // The Playwright config and test files are not part of the project's TypeScript program (they are excluded + // from tsconfig.json), so disable type-aware parsing and the profile's type-aware rules and lint them with + // only the non-type-aware rules. + // TODO: Replace this with the `@rushstack/eslint-config` `without-type-information` helper once that package + // is published and consumed by the node rigs. + files: ['playwright.config.ts', 'tests/**/*.ts'], + languageOptions: { + parserOptions: { + project: false, + projectService: false + } + }, + rules: { + '@typescript-eslint/naming-convention': 'off', + '@typescript-eslint/no-floating-promises': 'off', + '@typescript-eslint/no-for-in-array': 'off' + } + } ]; diff --git a/common/changes/@rushstack/eslint-config/heft-lint-flat-config-files_2026-09-12-04-00-00.json b/common/changes/@rushstack/eslint-config/heft-lint-flat-config-files_2026-09-12-04-00-00.json index f4dac8b2a35..3a31b8f141f 100644 --- a/common/changes/@rushstack/eslint-config/heft-lint-flat-config-files_2026-09-12-04-00-00.json +++ b/common/changes/@rushstack/eslint-config/heft-lint-flat-config-files_2026-09-12-04-00-00.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/eslint-config", - "comment": "Group the type-aware rules separately (exposed as `typeAwareRules`/`nonTypeAwareRules`) and add a `flat/without-type-information` helper for linting files that are not part of the TypeScript program with only the non-type-aware rules.", + "comment": "Group the profile's type-aware rules into an exported `typeAwareRules` set and add a `flat/without-type-information` helper for linting files that are not part of the TypeScript program with only the non-type-aware rules.", "type": "minor" } ] diff --git a/eslint/eslint-config/flat/profile/_common.js b/eslint/eslint-config/flat/profile/_common.js index 44ad99fb0d9..8c5dc8341c5 100644 --- a/eslint/eslint-config/flat/profile/_common.js +++ b/eslint/eslint-config/flat/profile/_common.js @@ -785,18 +785,4 @@ const commonConfig = [ } ]; -// Derive the non-type-aware rule group from the authored TypeScript source-file config so that it stays in sync -// with the rules above. Everything that is not part of typeAwareRules can be applied to files that lack type -// information. -const typeScriptSourceFileConfig = commonConfig.find( - (configObject) => - Array.isArray(configObject.files) && configObject.files.includes('**/*.ts') && configObject.rules -); -const nonTypeAwareRules = {}; -for (const [ruleName, ruleValue] of Object.entries(typeScriptSourceFileConfig.rules)) { - if (!(ruleName in typeAwareRules)) { - nonTypeAwareRules[ruleName] = ruleValue; - } -} - -module.exports = { commonNamingConventionSelectors, commonConfig, typeAwareRules, nonTypeAwareRules }; +module.exports = { commonNamingConventionSelectors, commonConfig, typeAwareRules }; diff --git a/eslint/eslint-config/flat/without-type-information.js b/eslint/eslint-config/flat/without-type-information.js index 5f51917a2fb..2fde93f4c73 100644 --- a/eslint/eslint-config/flat/without-type-information.js +++ b/eslint/eslint-config/flat/without-type-information.js @@ -1,7 +1,13 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -const { typeAwareRules } = require('./profile/_common'); +// The profile's type-aware rules, turned off. Keep this in sync with the typeAwareRules group in +// ./profile/_common.js. +const disabledTypeAwareRules = { + '@typescript-eslint/naming-convention': 'off', + '@typescript-eslint/no-floating-promises': 'off', + '@typescript-eslint/no-for-in-array': 'off' +}; // Returns ESLint flat-config objects that lint the specified files WITHOUT type information: type-aware parsing // is disabled and the profile's type-aware rules are turned off, leaving only the non-type-aware rules in effect. @@ -11,9 +17,6 @@ const { typeAwareRules } = require('./profile/_common'); // this, typescript-eslint reports a fatal parsing error because it cannot associate those files with the project, // and any type-aware rule would be unable to run. // -// If your ESLint configuration layers additional type-aware rules on top of this profile, pass their rule names -// via "additionalTypeAwareRuleNames" so that they are disabled as well. -// // IMPORTANT: These config objects must be included in your ESLint configuration AFTER the profile, so that they // override the profile's type-aware parser options and rules for the specified files. // @@ -25,12 +28,7 @@ const { typeAwareRules } = require('./profile/_common'); // ...nodeTrustedToolProfile, // ...withoutTypeInformation({ files: ['tests/**/*.ts', 'playwright.config.ts'] }) // ]; -function withoutTypeInformation({ files, additionalTypeAwareRuleNames = [] }) { - const disabledTypeAwareRules = {}; - for (const ruleName of [...Object.keys(typeAwareRules), ...additionalTypeAwareRuleNames]) { - disabledTypeAwareRules[ruleName] = 'off'; - } - +function withoutTypeInformation({ files }) { return [ { files, diff --git a/eslint/local-eslint-config/.gitignore b/eslint/local-eslint-config/.gitignore index 229cd2b88bf..281714b6678 100644 --- a/eslint/local-eslint-config/.gitignore +++ b/eslint/local-eslint-config/.gitignore @@ -1,4 +1,3 @@ /flat/mixins /flat/patch -/flat/profile -/flat/without-type-information.js \ No newline at end of file +/flat/profile \ No newline at end of file diff --git a/heft-plugins/heft-lint-plugin/src/Eslint.ts b/heft-plugins/heft-lint-plugin/src/Eslint.ts index 13353880a81..b0f2bf6858e 100644 --- a/heft-plugins/heft-lint-plugin/src/Eslint.ts +++ b/heft-plugins/heft-lint-plugin/src/Eslint.ts @@ -140,7 +140,7 @@ export class Eslint extends LinterBase< this.#sarifLogPath = sarifLogPath; this.#typeScriptFilenames = new Set( - tsProgram.getRootFileNames().map((filePath: string) => path.resolve(filePath)) + tsProgram.getRootFileNames().map((filePath: string) => 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 @@ -149,7 +149,9 @@ export class Eslint extends LinterBase< const typeScriptFilePatterns: string[] = []; for (const filePath of this.#typeScriptFilenames) { if (Path.isUnder(filePath, buildFolderPath)) { - typeScriptFilePatterns.push(Path.convertToSlashes(path.relative(buildFolderPath, filePath))); + // 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))); } } diff --git a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts index 7ac30fa1f49..272e46836ea 100644 --- a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts +++ b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts @@ -307,25 +307,29 @@ export default class LintPlugin implements IHeftTaskPlugin { 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(outDir); + outputFolderPaths.add(path.resolve(buildFolderPath, outDir)); } if (declarationDir) { - outputFolderPaths.add(declarationDir); + outputFolderPaths.add(path.resolve(buildFolderPath, declarationDir)); } } - const { buildFolderPath } = heftConfiguration; - return Array.from(outputFolderPaths, (outputFolderPath: string) => { - const relativePath: string = Path.convertToSlashes(path.relative(buildFolderPath, outputFolderPath)); - return `${relativePath}/**`; - }).filter( - (relativePath: string) => - relativePath !== '/**' && relativePath !== '../**' && !relativePath.startsWith('../') - ); + 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/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 44d45c284e9..4b78d53b57d 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,51 +11,6 @@ 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 = { localCommonConfig: [ { @@ -87,9 +42,15 @@ module.exports = { // understand where the dependency is coming from. '@rushstack/normalized-imports': 'warn', - // 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: Use of `void` to explicitly indicate that a floating promise is expected + // and allowed. + '@typescript-eslint/no-floating-promises': [ + 'error', + { + ignoreVoid: true, + checkThenables: true + } + ], // Rationale: Redeclaring a variable likely indicates a mistake in the code. 'no-redeclare': 'off', @@ -149,6 +110,36 @@ 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'], @@ -224,6 +215,5 @@ module.exports = { 'import/no-duplicates': 'off' } } - ], - localTypeAwareRules + ] }; 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 deleted file mode 100644 index ae0befc2299..00000000000 --- a/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/without-type-information.js +++ /dev/null @@ -1,23 +0,0 @@ -// 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 }) { - return baseWithoutTypeInformation({ - files, - additionalTypeAwareRuleNames: Object.keys(localTypeAwareRules) - }); -} - -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 deleted file mode 100644 index 26f4bda59df..00000000000 --- a/rigs/local-node-rig/profiles/default/includes/eslint/flat/without-type-information.js +++ /dev/null @@ -1,4 +0,0 @@ -// 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');