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/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/heft-typescript-plugin/heft-lint-flat-config-emit-folders_2026-09-16-05-00-00.json b/common/changes/@rushstack/heft-typescript-plugin/heft-lint-flat-config-emit-folders_2026-09-16-05-00-00.json new file mode 100644 index 00000000000..1983a8863a0 --- /dev/null +++ b/common/changes/@rushstack/heft-typescript-plugin/heft-lint-flat-config-emit-folders_2026-09-16-05-00-00.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-typescript-plugin", + "comment": "Add `emitFolderPaths` to the `IChangedFilesHookOptions` provided by the TypeScript plugin accessor, so that consumers can identify (and avoid processing) the folders that TypeScript emits output to, including `additionalModuleKindsToEmit` folders.", + "type": "minor" + } + ] +} diff --git a/common/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/common/reviews/api/heft-typescript-plugin.api.md b/common/reviews/api/heft-typescript-plugin.api.md index 51276bd9191..331ed2fded3 100644 --- a/common/reviews/api/heft-typescript-plugin.api.md +++ b/common/reviews/api/heft-typescript-plugin.api.md @@ -29,6 +29,7 @@ export interface _IBaseTypeScriptTool; + emitFolderPaths: ReadonlySet; // (undocumented) program: _TTypeScript.Program; } diff --git a/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..58479ec0325 100644 --- a/heft-plugins/heft-lint-plugin/src/Eslint.ts +++ b/heft-plugins/heft-lint-plugin/src/Eslint.ts @@ -5,20 +5,33 @@ 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; + /** + * The absolute paths of the folders that TypeScript emits output to. These are ignored when enumerating the + * additional files to lint so that generated output is not linted. + */ + emitFolderPaths?: ReadonlySet; +} + +interface IEslintOptions extends IEslintInitializeOptions { eslintPackage: typeof TEslint | typeof TEslintLegacy; eslintTimings: Map; } @@ -82,19 +95,25 @@ 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; + 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 +125,12 @@ export class Eslint extends LinterBase path.resolve(buildFolderPath, filePath)) + ); + // ESLint configuration paths are relative to the project folder. Compute the project-relative paths of the + // files in the TypeScript program so that the injected program can be scoped to just those files, and so + // that those files can be excluded when enumerating the additional files to lint. Only files under the + // project folder can be expressed as ESLint configuration patterns. + const typeScriptFilePatterns: string[] = []; + for (const filePath of this.#typeScriptFilenames) { + if (Path.isUnder(filePath, buildFolderPath)) { + // filePath is already an absolute path under buildFolderPath, so strip the prefix (plus the separator) + // instead of recomputing the relative path. + typeScriptFilePatterns.push(Path.convertToSlashes(filePath.slice(buildFolderPath.length + 1))); + } + } + + // Ignore the folders that TypeScript emits output to so that generated output is not enumerated as an + // additional file to lint. Only folders under the project folder can be expressed as ESLint patterns. + const emitFolderIgnorePatterns: string[] = []; + for (const emitFolderPath of emitFolderPaths ?? []) { + if (Path.isUnder(emitFolderPath, buildFolderPath)) { + emitFolderIgnorePatterns.push( + `${Path.convertToSlashes(emitFolderPath.slice(buildFolderPath.length + 1))}/**` + ); + } + } + 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 +227,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 and the TypeScript output folders so + // that enumeration returns only the files that are not part of the program and are not generated + // output. + name: `${pluginName}/ignore-typescript-program-files`, + ignores: [...typeScriptFilePatterns, ...emitFolderIgnorePatterns] + }, + ruleFilter: () => false + }); + } + this.#eslintTimings = eslintTimings; } @@ -221,8 +295,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 +305,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, so they can be compared directly against the TypeScript program's + // (already resolved) file paths. Files that are part of the program are excluded; everything else the + // ESLint configuration selects (and that is not ignored, including the TypeScript output folders) is linted + // as an additional file. + const additionalFilePaths: string[] = []; + for (const { filePath } of lintResults) { + if (!typeScriptFilenames.has(filePath)) { + additionalFilePaths.push(filePath); + } + } + // 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 +391,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 +451,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(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..585ffbc075a 100644 --- a/heft-plugins/heft-lint-plugin/src/LintPlugin.ts +++ b/heft-plugins/heft-lint-plugin/src/LintPlugin.ts @@ -42,6 +42,12 @@ interface ILintOptions { fix?: boolean; sarifLogPath?: string; changedFiles?: ReadonlySet; + includeAdditionalFiles: boolean; + /** + * The absolute paths of the folders that TypeScript emits output to, ignored when enumerating additional + * files so that generated output is not linted. + */ + emitFolderPaths: ReadonlySet; } function checkFix(taskSession: IHeftTaskSession, pluginOptions?: ILintPluginOptions): boolean { @@ -105,6 +111,9 @@ export default class LintPlugin implements IHeftTaskPlugin { // Use the changed files hook to collect the files and programs from TypeScript let typescriptChangedFiles: [IExtendedProgram, ReadonlySet][] = []; + // The absolute paths of the folders that TypeScript emits output to, aggregated across all programs. These + // are ignored when enumerating the additional files to lint so that generated output is not linted. + const emitFolderPaths: Set = new Set(); taskSession.requestAccessToPluginByName( TYPESCRIPT_PLUGIN_PACKAGE_NAME, TYPESCRIPT_PLUGIN_NAME, @@ -118,6 +127,9 @@ export default class LintPlugin implements IHeftTaskPlugin { changedFilesHookOptions.program as IExtendedProgram, changedFilesHookOptions.changedFiles as ReadonlySet ]); + for (const emitFolderPath of changedFilesHookOptions.emitFolderPaths) { + emitFolderPaths.add(emitFolderPath); + } }); } ); @@ -131,9 +143,20 @@ export default class LintPlugin implements IHeftTaskPlugin { taskSession ); typescriptChangedFiles.push([tsProgram, new Set(tsProgram.getSourceFiles())]); + // In standalone mode there is no TypeScript plugin to report emit folders, so derive them from the + // program's compiler options. (additionalModuleKindsToEmit output folders are not available here.) + const { outDir, declarationDir } = tsProgram.getCompilerOptions(); + if (outDir) { + emitFolderPaths.add(path.resolve(heftConfiguration.buildFolderPath, outDir)); + } + + if (declarationDir) { + emitFolderPaths.add(path.resolve(heftConfiguration.buildFolderPath, declarationDir)); + } } // Run the linters to completion. Linters emit errors and warnings to the logger. + let includeAdditionalFiles: boolean = true; for (const [tsProgram, changedFiles] of typescriptChangedFiles) { try { await this.#lintAsync({ @@ -142,13 +165,17 @@ export default class LintPlugin implements IHeftTaskPlugin { tsProgram, changedFiles, fix, - sarifLogPath + sarifLogPath, + includeAdditionalFiles, + emitFolderPaths }); } 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,25 +249,38 @@ 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, + emitFolderPaths + } = 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, fix, sarifLogPath, + emitFolderPaths, scopedLogger: taskSession.logger, 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) + ); } if (this.#tslintConfigFilePath && this.#tslintToolPath) { @@ -253,21 +293,29 @@ export default class LintPlugin implements IHeftTaskPlugin { buildFolderPath: heftConfiguration.buildFolderPath, buildMetadataFolderPath: taskSession.tempFolderPath }); - linters.push(tslintLinter); + lintOperations.push(() => + this.#runLinterAsync(tslintLinter, heftConfiguration, tsProgram, changedFiles) + ); } // Now that we know we have initialized properly, run the linter(s) - await Promise.all(linters.map((linter) => this.#runLinterAsync(linter, tsProgram, changedFiles))); + await Promise.all(lintOperations.map((lintOperation) => lintOperation())); } async #runLinterAsync( linter: LinterBase, + heftConfiguration: HeftConfiguration, tsProgram: IExtendedProgram, changedFiles?: ReadonlySet | undefined ): Promise { linter.printVersionHeader(); - const typeScriptFilenames: Set = new Set(tsProgram.getRootFileNames()); + // Resolve the program's root file names against the project folder so that they can be compared against the + // absolute paths that ESLint reports for the files it selects. + const { buildFolderPath } = heftConfiguration; + const typeScriptFilenames: Set = new Set( + tsProgram.getRootFileNames().map((filePath: string) => path.resolve(buildFolderPath, filePath)) + ); await linter.performLintingAsync({ tsProgram, typeScriptFilenames, diff --git a/heft-plugins/heft-lint-plugin/src/LinterBase.ts b/heft-plugins/heft-lint-plugin/src/LinterBase.ts index 6e3404e5433..ca7c0d9159e 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; @@ -91,11 +106,22 @@ export abstract class LinterBase { const commonDirectory: string = options.tsProgram.getCommonSourceDirectory(); + // Files to lint that are not part of the TypeScript program (subclasses may enumerate their own). The + // default implementation returns none. + const extraSourceFiles: Iterable = await this.getExtraSourceFilesToLintAsync( + options.typeScriptFilenames + ); + 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,7 +193,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 | ISourceFileToLint)[] = [ + ...options.tsProgram.getSourceFiles(), + ...extraSourceFiles + ]; + 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 +214,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 +252,21 @@ 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 the TypeScript program. Subclasses may override this to + * enumerate additional files selected by the linter configuration. The default implementation returns none. + */ + protected async getExtraSourceFilesToLintAsync( + typeScriptFilenames: 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 +279,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/heft-plugins/heft-typescript-plugin/src/TypeScriptPlugin.ts b/heft-plugins/heft-typescript-plugin/src/TypeScriptPlugin.ts index 32dff7658e2..6372a4b76a3 100644 --- a/heft-plugins/heft-typescript-plugin/src/TypeScriptPlugin.ts +++ b/heft-plugins/heft-typescript-plugin/src/TypeScriptPlugin.ts @@ -128,6 +128,12 @@ export interface IPartialTsconfig { export interface IChangedFilesHookOptions { program: TTypescript.Program; changedFiles?: ReadonlySet; + /** + * The absolute paths of the folders that the TypeScript compiler emits output to. This includes the + * `outDir` and `declarationDir` from the compiler options as well as any `additionalModuleKindsToEmit` + * output folders (for example `lib-esm`). Consumers can use these to avoid processing generated output. + */ + emitFolderPaths: ReadonlySet; } /** @@ -381,7 +387,25 @@ export default class TypeScriptPlugin implements IHeftTaskPlugin { ) => { // Provide the typescript program dependent plugins if (this.accessor.onChangedFilesHook.isUsed()) { - this.accessor.onChangedFilesHook.call({ program, changedFiles }); + // Collect the folders that the compiler emits output to so that consumers can avoid processing + // generated output. `additionalModuleKindsToEmit` output folders (for example `lib-esm`) are not + // part of the compiler options, so they must be included from the Heft configuration. + const compilerOptions: TTypescript.CompilerOptions = program.getCompilerOptions(); + const emitFolderPaths: Set = new Set(); + const { outDir, declarationDir } = compilerOptions; + if (outDir) { + emitFolderPaths.add(path.resolve(heftConfiguration.buildFolderPath, outDir)); + } + + if (declarationDir) { + emitFolderPaths.add(path.resolve(heftConfiguration.buildFolderPath, declarationDir)); + } + + for (const { outFolderName } of typeScriptConfigurationJson?.additionalModuleKindsToEmit ?? []) { + emitFolderPaths.add(path.resolve(heftConfiguration.buildFolderPath, outFolderName)); + } + + this.accessor.onChangedFilesHook.call({ program, changedFiles, emitFolderPaths }); } } }; diff --git a/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/profile/_common.js b/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/profile/_common.js index 4b78d53b57d..194a5fdbbc1 100644 --- a/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/profile/_common.js +++ b/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/profile/_common.js @@ -11,7 +11,53 @@ const headersEslintPlugin = require('eslint-plugin-headers'); const nodeImportResolverPath = require.resolve('eslint-import-resolver-node'); +// These localCommonConfig rules require type information (i.e. the TypeScript program). They are grouped +// separately so that TypeScript files which are NOT part of the project's TypeScript program can be linted with +// only the non-type-aware rules. See the "without-type-information" helper. +const localTypeAwareRules = { + // Rationale: Use of `void` to explicitly indicate that a floating promise is expected + // and allowed. + '@typescript-eslint/no-floating-promises': [ + 'error', + { + ignoreVoid: true, + checkThenables: true + } + ], + + // Docs: https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/naming-convention.md + '@typescript-eslint/naming-convention': [ + 'warn', + ...expandNamingConventionSelectors([ + ...commonNamingConventionSelectors, + { + selectors: ['method'], + modifiers: ['async'], + enforceLeadingUnderscoreWhenPrivate: true, + + format: null, + custom: { + regex: '^_?[a-zA-Z]\\w*Async$', + match: true + }, + leadingUnderscore: 'allow', + + filter: { + regex: [ + // Specifically allow ts-command-line's "onExecute" function. + '^onExecute$' + ] + .map((x) => `(${x})`) + .join('|'), + match: false + } + } + ]) + ] +}; + module.exports = { + localTypeAwareRules, localCommonConfig: [ { files: ['**/*.ts', '**/*.tsx'], @@ -42,15 +88,9 @@ module.exports = { // understand where the dependency is coming from. '@rushstack/normalized-imports': 'warn', - // Rationale: Use of `void` to explicitly indicate that a floating promise is expected - // and allowed. - '@typescript-eslint/no-floating-promises': [ - 'error', - { - ignoreVoid: true, - checkThenables: true - } - ], + // Type-aware rules (require the TypeScript program) are grouped in localTypeAwareRules so that files + // outside the TypeScript program can be linted with only the non-type-aware rules. + ...localTypeAwareRules, // Rationale: Redeclaring a variable likely indicates a mistake in the code. 'no-redeclare': 'off', @@ -110,36 +150,6 @@ module.exports = { } ], - // Docs: https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/naming-convention.md - '@typescript-eslint/naming-convention': [ - 'warn', - ...expandNamingConventionSelectors([ - ...commonNamingConventionSelectors, - { - selectors: ['method'], - modifiers: ['async'], - enforceLeadingUnderscoreWhenPrivate: true, - - format: null, - custom: { - regex: '^_?[a-zA-Z]\\w*Async$', - match: true - }, - leadingUnderscore: 'allow', - - filter: { - regex: [ - // Specifically allow ts-command-line's "onExecute" function. - '^onExecute$' - ] - .map((x) => `(${x})`) - .join('|'), - match: false - } - } - ]) - ], - // Require `node:` protocol for imports of Node.js built-in modules 'import/enforce-node-protocol-usage': ['warn', 'always'], diff --git a/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/without-type-information.js b/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/without-type-information.js new file mode 100644 index 00000000000..0dbad56e3bc --- /dev/null +++ b/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/without-type-information.js @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const { + withoutTypeInformation: baseWithoutTypeInformation +} = require('@rushstack/eslint-config/flat/without-type-information'); + +const { localTypeAwareRules } = require('./profile/_common'); + +// Like @rushstack/eslint-config's withoutTypeInformation(), but also disables the type-aware rules that this +// rig layers on top of the profile (localCommonConfig). Use this for TypeScript files that are selected by your +// ESLint configuration but are not part of the project's TypeScript program (for example config files or tests +// that are not included by tsconfig.json). +// +// IMPORTANT: These config objects must be included in your ESLint configuration AFTER the profile. +function withoutTypeInformation({ files }) { + const disabledLocalTypeAwareRules = {}; + for (const ruleName of Object.keys(localTypeAwareRules)) { + disabledLocalTypeAwareRules[ruleName] = 'off'; + } + + return [ + // Disables type-aware parsing and the base profile's type-aware rules. + ...baseWithoutTypeInformation({ files }), + // Also disable the type-aware rules that this rig adds on top of the base profile. + { + files, + rules: disabledLocalTypeAwareRules + } + ]; +} + +module.exports = { withoutTypeInformation }; diff --git a/rigs/local-node-rig/profiles/default/includes/eslint/flat/without-type-information.js b/rigs/local-node-rig/profiles/default/includes/eslint/flat/without-type-information.js new file mode 100644 index 00000000000..26f4bda59df --- /dev/null +++ b/rigs/local-node-rig/profiles/default/includes/eslint/flat/without-type-information.js @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +module.exports = require('local-eslint-config/flat/without-type-information');