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/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..ff4ac1f6fad 100644 --- a/build-tests/eslint-9-test/src/__snapshots__/sarif.test.ts.snap +++ b/build-tests/eslint-9-test/src/__snapshots__/sarif.test.ts.snap @@ -16,6 +16,16 @@ Object { "uri": "src/sarif.test.ts", }, }, + Object { + "location": Object { + "uri": "eslint.config.js", + }, + }, + Object { + "location": Object { + "uri": "src/non-program.custom", + }, + }, ], "results": Array [ Object { @@ -78,6 +88,36 @@ Object { }, ], }, + Object { + "level": "warning", + "locations": Array [ + Object { + "physicalLocation": Object { + "artifactLocation": Object { + "index": 3, + "uri": "src/non-program.custom", + }, + "region": Object { + "endColumn": 14, + "endLine": 1, + "startColumn": 1, + "startLine": 1, + }, + }, + }, + ], + "message": Object { + "text": "'missingGlobal' is not defined.", + }, + "ruleId": "no-undef", + "ruleIndex": 2, + "suppressions": Array [ + Object { + "justification": "", + "kind": "external", + }, + ], + }, ], "tool": Object { "driver": Object { @@ -100,6 +140,14 @@ Object { "text": "Enforce naming conventions for everything across a codebase", }, }, + Object { + "helpUri": "https://eslint.org/docs/latest/rules/no-undef", + "id": "no-undef", + "properties": Object {}, + "shortDescription": Object { + "text": "Disallow the use of undeclared variables unless mentioned in \`/*global */\` comments", + }, + }, ], "version": "9.37.0", }, diff --git a/build-tests/eslint-9-test/src/non-program.custom b/build-tests/eslint-9-test/src/non-program.custom new file mode 100644 index 00000000000..7b7f2da4753 --- /dev/null +++ b/build-tests/eslint-9-test/src/non-program.custom @@ -0,0 +1 @@ +missingGlobal; \ No newline at end of file diff --git a/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-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/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/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..93e3410ae7a --- /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": "", + "type": "none" + } + ] +} 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/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/heft-plugins/heft-lint-plugin/src/Eslint.ts b/heft-plugins/heft-lint-plugin/src/Eslint.ts index 81ca9c87ac9..23c6e6fc5c8 100644 --- a/heft-plugins/heft-lint-plugin/src/Eslint.ts +++ b/heft-plugins/heft-lint-plugin/src/Eslint.ts @@ -5,20 +5,28 @@ 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'; 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 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; } @@ -82,19 +90,60 @@ const ESLINT_LEGACY_CONFIG_FILENAMES: Set = new Set([ LEGACY_ESLINTRC_CJS_FILENAME ]); +// 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; + +// 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 { + 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 +// 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; 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; + readonly #includeAdditionalFiles: boolean; protected constructor(options: IEslintOptions) { super('eslint', options); @@ -106,9 +155,11 @@ export class Eslint extends LinterBase Path.convertToSlashes(path.resolve(buildFolderPath, filePath))) + ); + // 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; 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. @@ -178,7 +245,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 + // files that are not part of the program. + name: `${pluginName}/ignore-typescript-program-files`, + ignores: typeScriptFilePatterns + }, + ruleFilter: () => false + }); + } + this.#eslintTimings = eslintTimings; } @@ -221,8 +312,8 @@ export class Eslint extends LinterBase { - 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); @@ -231,7 +322,8 @@ export class Eslint extends LinterBase + ): Promise> { + if (!this.#includeAdditionalFiles || !this.#fileEnumerator) { + return []; + } + + // 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 (with the platform-native separator), so normalize them to forward + // slashes to compare against the TypeScript programs' (already normalized) file paths. Files that are part + // of any TypeScript program are excluded (so they are not linted both as program files and as additional + // files, which matters when there are multiple programs); everything else the ESLint configuration selects + // (and that it does not ignore) is linted as an additional file. Generated output is excluded by the ESLint + // configuration's own `ignores` (the shared config ignores build-output folders such as `lib`, `lib-*`, + // `dist`, `temp`, and `coverage`). + const additionalFilePaths: string[] = []; + for (const { filePath } of lintResults) { + const normalizedFilePath: string = Path.convertToSlashes(filePath); + if (!programFilenames.has(normalizedFilePath)) { + additionalFilePaths.push(normalizedFilePath); + } + } + // Sort for a stable ordering across runs. ESLint reports absolute paths, so a default lexicographic sort + // is sufficient. + additionalFilePaths.sort(); + + const additionalLintFiles: ISourceFileToLint[] = new Array(additionalFilePaths.length); + await Async.forEachAsync( + additionalFilePaths, + async (filePath: string, index: number) => { + additionalLintFiles[index] = { + fileName: filePath, + // `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 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 | ISourceFileToLint + ): Promise { const sourceFileEslintConfiguration: TEslint.Linter.Config = await this.#linter.calculateConfigForFile( sourceFile.fileName ); @@ -272,7 +412,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 +472,17 @@ export class Eslint extends LinterBase, + 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(Path.convertToSlashes(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})` + ); +} diff --git a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts index f00a078a881..b62d6afcea2 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 { LinterBase } from './LinterBase'; import { Eslint } from './Eslint'; @@ -42,6 +42,12 @@ interface ILintOptions { fix?: boolean; sarifLogPath?: string; changedFiles?: ReadonlySet; + 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 { @@ -134,6 +140,18 @@ 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 { await this.#lintAsync({ @@ -142,13 +160,17 @@ export default class LintPlugin implements IHeftTaskPlugin { tsProgram, changedFiles, fix, - sarifLogPath + sarifLogPath, + includeAdditionalFiles, + allProgramFilenames }); } 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 +244,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, + allProgramFilenames + } = 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 +269,12 @@ export default class LintPlugin implements IHeftTaskPlugin { linterToolPath: this.#eslintToolPath, linterConfigFilePath: this.#eslintConfigFilePath, buildFolderPath: heftConfiguration.buildFolderPath, - buildMetadataFolderPath: taskSession.tempFolderPath + buildMetadataFolderPath: taskSession.tempFolderPath, + includeAdditionalFiles }); - linters.push(eslintLinter); + lintOperations.push(() => + this.#runLinterAsync(eslintLinter, heftConfiguration, tsProgram, changedFiles, allProgramFilenames) + ); } if (this.#tslintConfigFilePath && this.#tslintToolPath) { @@ -253,24 +287,38 @@ export default class LintPlugin implements IHeftTaskPlugin { buildFolderPath: heftConfiguration.buildFolderPath, buildMetadataFolderPath: taskSession.tempFolderPath }); - linters.push(tslintLinter); + lintOperations.push(() => + this.#runLinterAsync(tslintLinter, heftConfiguration, tsProgram, changedFiles, allProgramFilenames) + ); } // 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, + heftConfiguration: HeftConfiguration, tsProgram: IExtendedProgram, - changedFiles?: ReadonlySet | undefined + changedFiles: ReadonlySet | undefined, + allProgramFilenames: ReadonlySet ): 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. 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.convertToSlashes(path.resolve(buildFolderPath, filePath))) + ); 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 6e3404e5433..f7bafd0acaa 100644 --- a/heft-plugins/heft-lint-plugin/src/LinterBase.ts +++ b/heft-plugins/heft-lint-plugin/src/LinterBase.ts @@ -27,6 +27,21 @@ export interface ILinterBaseOptions { sarifLogPath?: string; } +/** + * 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; + /** + * 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 { tsProgram: IExtendedProgram; @@ -35,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. */ @@ -91,11 +114,23 @@ export abstract class LinterBase { const commonDirectory: string = options.tsProgram.getCommonSourceDirectory(); + // 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.allProgramFilenames + ); + const relativePaths: Map = new Map(); // 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 extraSourceFile of extraSourceFiles) { + lintFilenames.add(extraSourceFile.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,13 +202,29 @@ 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 | ISourceFileToLint)[] = [ + ...options.tsProgram.getSourceFiles(), + ...extraSourceFiles + ]; + 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) || ''; @@ -181,7 +232,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,9 +270,24 @@ export abstract class LinterBase { this._terminal.writeVerboseLine(`Lint: ${duration}ms (${fileCount} files)`); } - protected async getSourceFileHashAsync(sourceFile: IExtendedSourceFile): Promise { + /** + * 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( + programFilenames: ReadonlySet + ): Promise> { + return []; + } + + protected async getSourceFileHashAsync( + 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'); @@ -234,7 +300,9 @@ export abstract class LinterBase { protected abstract getCacheVersionAsync(): Promise; - protected abstract lintFileAsync(sourceFile: IExtendedSourceFile): Promise; + protected abstract lintFileAsync( + sourceFile: IExtendedSourceFile | ISourceFileToLint + ): Promise; protected abstract lintingFinishedAsync(lintResults: TLintResult[]): Promise; 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 4b78d53b57d..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 @@ -11,8 +11,62 @@ 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: [ + // 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: { @@ -42,15 +96,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 +158,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'); 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/**'] + } +];