diff --git a/apps/playwright-browser-tunnel/eslint.config.js b/apps/playwright-browser-tunnel/eslint.config.js index c15e6077310..00e4e13f533 100644 --- a/apps/playwright-browser-tunnel/eslint.config.js +++ b/apps/playwright-browser-tunnel/eslint.config.js @@ -14,5 +14,24 @@ 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 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/apps/playwright-browser-tunnel/playwright.config.ts b/apps/playwright-browser-tunnel/playwright.config.ts index 5d826145aa7..2e354306e1c 100644 --- a/apps/playwright-browser-tunnel/playwright.config.ts +++ b/apps/playwright-browser-tunnel/playwright.config.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ diff --git a/apps/playwright-browser-tunnel/tests/testFixture.ts b/apps/playwright-browser-tunnel/tests/testFixture.ts index 0f0e0dafc90..ba84f374568 100644 --- a/apps/playwright-browser-tunnel/tests/testFixture.ts +++ b/apps/playwright-browser-tunnel/tests/testFixture.ts @@ -2,14 +2,18 @@ // See LICENSE in the project root for license information. import { test as base } from '@playwright/test'; -import { tunneledBrowser } from '../src/tunneledBrowserConnection'; -export const test = base.extend({ +import { + createTunneledBrowserAsync, + type IDisposableTunneledBrowser +} from '../src/tunneledBrowserConnection'; + +export const test: typeof base = base.extend({ browser: [ async ({ browserName, launchOptions, channel, headless }, use) => { - console.log(`Starting tunnel server for browser: ${browserName}, channel: ${channel}`); + console.info(`Starting tunnel server for browser: ${browserName}, channel: ${channel}`); - await using tunnel = await tunneledBrowser(browserName, { + await using tunnel: IDisposableTunneledBrowser = await createTunneledBrowserAsync(browserName, { channel, headless, ...launchOptions diff --git a/build-tests/eslint-9-test/.eslint-bulk-suppressions.json b/build-tests/eslint-9-test/.eslint-bulk-suppressions.json index 961e6033858..467159daa95 100644 --- a/build-tests/eslint-9-test/.eslint-bulk-suppressions.json +++ b/build-tests/eslint-9-test/.eslint-bulk-suppressions.json @@ -4,6 +4,11 @@ "file": "src/index.ts", "scopeId": ".", "rule": "@typescript-eslint/naming-convention" + }, + { + "file": "src/non-program.custom", + "scopeId": ".", + "rule": "no-undef" } ] } diff --git a/build-tests/eslint-9-test/eslint.config.js b/build-tests/eslint-9-test/eslint.config.js index 75eb0c727fc..ca63423a8d6 100644 --- a/build-tests/eslint-9-test/eslint.config.js +++ b/build-tests/eslint-9-test/eslint.config.js @@ -7,6 +7,9 @@ const nodeTrustedToolProfile = require('local-node-rig/profiles/default/includes const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); module.exports = [ + { + ignores: ['coverage/**'] + }, ...nodeTrustedToolProfile, ...friendlyLocalsMixin, { @@ -25,5 +28,11 @@ module.exports = [ tsconfigRootDir: __dirname } } + }, + { + files: ['**/*.custom'], + rules: { + 'no-undef': 'warn' + } } ]; diff --git a/build-tests/eslint-9-test/src/__snapshots__/sarif.test.ts.snap b/build-tests/eslint-9-test/src/__snapshots__/sarif.test.ts.snap index 0ddfa4d6a6f..7ce2a8bf711 100644 --- a/build-tests/eslint-9-test/src/__snapshots__/sarif.test.ts.snap +++ b/build-tests/eslint-9-test/src/__snapshots__/sarif.test.ts.snap @@ -16,6 +16,11 @@ Object { "uri": "src/sarif.test.ts", }, }, + Object { + "location": Object { + "uri": "src/non-program.custom", + }, + }, ], "results": Array [ Object { @@ -78,6 +83,36 @@ Object { }, ], }, + Object { + "level": "warning", + "locations": Array [ + Object { + "physicalLocation": Object { + "artifactLocation": Object { + "index": 2, + "uri": "src/non-program.custom", + }, + "region": Object { + "endColumn": 14, + "endLine": 1, + "startColumn": 1, + "startLine": 1, + }, + }, + }, + ], + "message": Object { + "text": "'missingGlobal' is not defined.", + }, + "ruleId": "no-undef", + "ruleIndex": 2, + "suppressions": Array [ + Object { + "justification": "", + "kind": "external", + }, + ], + }, ], "tool": Object { "driver": Object { @@ -100,6 +135,14 @@ Object { "text": "Enforce naming conventions for everything across a codebase", }, }, + Object { + "helpUri": "https://eslint.org/docs/latest/rules/no-undef", + "id": "no-undef", + "properties": Object {}, + "shortDescription": Object { + "text": "Disallow the use of undeclared variables unless mentioned in \`/*global */\` comments", + }, + }, ], "version": "9.37.0", }, diff --git a/build-tests/eslint-9-test/src/non-program.custom b/build-tests/eslint-9-test/src/non-program.custom new file mode 100644 index 00000000000..7b7f2da4753 --- /dev/null +++ b/build-tests/eslint-9-test/src/non-program.custom @@ -0,0 +1 @@ +missingGlobal; \ No newline at end of file diff --git a/common/changes/@rushstack/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..3a31b8f141f --- /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 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/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/eslint/eslint-config/flat/profile/_common.js b/eslint/eslint-config/flat/profile/_common.js index 4513f97c146..8c5dc8341c5 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,4 @@ const commonConfig = [ } ]; -module.exports = { commonNamingConventionSelectors, commonConfig }; +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 new file mode 100644 index 00000000000..2fde93f4c73 --- /dev/null +++ b/eslint/eslint-config/flat/without-type-information.js @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// 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. +// +// 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. +// +// 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 }) { + 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/heft-plugins/heft-lint-plugin/src/Eslint.ts b/heft-plugins/heft-lint-plugin/src/Eslint.ts index 81ca9c87ac9..b0f2bf6858e 100644 --- a/heft-plugins/heft-lint-plugin/src/Eslint.ts +++ b/heft-plugins/heft-lint-plugin/src/Eslint.ts @@ -11,10 +11,10 @@ import type * as TEslintLegacy from 'eslint-8'; import * as semver from 'semver'; import stableStringify from 'json-stable-stringify-without-jsonify'; -import { FileError, FileSystem } from '@rushstack/node-core-library'; +import { Async, FileError, FileSystem, Path } from '@rushstack/node-core-library'; import type { HeftConfiguration } from '@rushstack/heft'; -import { LinterBase, type ILinterBaseOptions } from './LinterBase'; +import { LinterBase, type IAdditionalLintFile, type ILinterBaseOptions } from './LinterBase'; import type { IExtendedSourceFile } from './internalTypings/TypeScriptInternals'; import { name as pluginName, version as pluginVersion } from '../package.json'; @@ -81,20 +81,29 @@ const ESLINT_LEGACY_CONFIG_FILENAMES: Set = new Set([ LEGACY_ESLINTRC_JS_FILENAME, LEGACY_ESLINTRC_CJS_FILENAME ]); +const ESLINT_DEFAULT_EXTENSIONS: Set = new Set(['.js', '.mjs', '.cjs']); -export class Eslint extends LinterBase { +// Limits the number of additional files that are read from disk concurrently while enumerating the files to +// lint that are not part of the TypeScript program. +const MAX_ADDITIONAL_FILE_READ_CONCURRENCY: number = 10; + +export class Eslint extends LinterBase< + TEslint.ESLint.LintResult | TEslintLegacy.ESLint.LintResult, + IAdditionalLintFile +> { readonly #eslintPackage: typeof TEslint | typeof TEslintLegacy; readonly #eslintPackageVersion: semver.SemVer; readonly #linter: TEslint.ESLint | TEslintLegacy.ESLint; readonly #eslintTimings: Map = new Map(); - readonly #currentFixMessages: (TEslint.Linter.LintMessage | TEslintLegacy.Linter.LintMessage)[] = - []; + readonly #currentFixMessages: (TEslint.Linter.LintMessage | TEslintLegacy.Linter.LintMessage)[] = []; readonly #fixMessagesByResult: Map< TEslint.ESLint.LintResult | TEslintLegacy.ESLint.LintResult, (TEslint.Linter.LintMessage | TEslintLegacy.Linter.LintMessage)[] > = new Map(); readonly #sarifLogPath: string | undefined; readonly #configHashMap: WeakMap = new WeakMap(); + readonly #fileEnumerator: TEslint.ESLint | undefined; + readonly #typeScriptFilenames: ReadonlySet; protected constructor(options: IEslintOptions) { super('eslint', options); @@ -106,7 +115,8 @@ export class Eslint extends LinterBase path.resolve(buildFolderPath, filePath)) + ); + // ESLint configuration paths are relative to the project folder. Compute the project-relative paths of the + // files in the TypeScript program so that the injected program can be scoped to just those files, and so + // that those files can be excluded when enumerating the additional files to lint. Only files under the + // project folder can be expressed as ESLint configuration patterns. + const typeScriptFilePatterns: string[] = []; + for (const filePath of this.#typeScriptFilenames) { + if (Path.isUnder(filePath, buildFolderPath)) { + // filePath is already an absolute path under buildFolderPath, so strip the prefix (plus the separator) + // instead of recomputing the relative path. + typeScriptFilePatterns.push(Path.convertToSlashes(filePath.slice(buildFolderPath.length + 1))); + } + } + let overrideConfig: TEslint.Linter.Config | TEslintLegacy.Linter.Config | undefined; let fixFn: Exclude; if (fix) { @@ -178,7 +204,12 @@ export class Eslint extends LinterBase= 9) { + const flatEslintPackage: typeof TEslint = eslintPackage as typeof TEslint; + // A separate instance is used purely to enumerate the files selected by the ESLint configuration that are + // not part of the TypeScript program. Rules are disabled so that this pass only resolves the file list. + this.#fileEnumerator = new flatEslintPackage.ESLint({ + cwd: buildFolderPath, + errorOnUnmatchedPattern: false, + overrideConfigFile: linterConfigFilePath, + overrideConfig: { + // This is the label for the flat-config object (used in ESLint debug output/config inspection); it is + // not a plugin reference. It ignores the TypeScript program files so enumeration returns only the + // additional files. + name: `${pluginName}/ignore-typescript-program-files`, + ignores: [...typeScriptFilePatterns, ...(additionalFileIgnorePatterns || [])] + }, + ruleFilter: () => false + }); + } + this.#eslintTimings = eslintTimings; } @@ -250,11 +300,60 @@ export class Eslint extends LinterBase> { + if (!this.#fileEnumerator) { + return new Set(); + } + + const lintResults: TEslint.ESLint.LintResult[] = await this.#fileEnumerator.lintFiles(['.']); + + // ESLint reports absolute file paths, so they can be compared directly against the TypeScript program's + // (already resolved) file paths. Files that ESLint lints by default (for example ".js"/".cjs"/".mjs" + // configuration files) are excluded because they are not TypeScript sources selected by this feature. + const additionalFilePaths: string[] = []; + for (const { filePath } of lintResults) { + if ( + !this.#typeScriptFilenames.has(filePath) && + !ESLINT_DEFAULT_EXTENSIONS.has(path.extname(filePath)) + ) { + additionalFilePaths.push(filePath); + } + } + // Sort for a stable ordering across runs. + additionalFilePaths.sort((left: string, right: string) => { + if (left < right) { + return -1; + } else if (left > right) { + return 1; + } else { + return 0; + } + }); + + const additionalLintFiles: IAdditionalLintFile[] = new Array(additionalFilePaths.length); + await Async.forEachAsync( + additionalFilePaths, + async (filePath: string, index: number) => { + additionalLintFiles[index] = { + kind: 'additional', + fileName: filePath, + text: await FileSystem.readFileAsync(filePath), + version: '' + }; + }, + { concurrency: MAX_ADDITIONAL_FILE_READ_CONCURRENCY } + ); + + return new Set(additionalLintFiles); + } + protected override async getCacheVersionAsync(): Promise { return `${this.#eslintPackageVersion.version}_${process.version}`; } - protected override async getSourceFileHashAsync(sourceFile: IExtendedSourceFile): Promise { + protected override async getSourceFileHashAsync( + sourceFile: IExtendedSourceFile | IAdditionalLintFile + ): Promise { const sourceFileEslintConfiguration: TEslint.Linter.Config = await this.#linter.calculateConfigForFile( sourceFile.fileName ); @@ -272,7 +371,7 @@ export class Eslint extends LinterBase { const lintResults: TEslint.ESLint.LintResult[] | TEslintLegacy.ESLint.LintResult[] = await this.#linter.lintText(sourceFile.text, { filePath: sourceFile.fileName }); @@ -332,7 +431,13 @@ export class Eslint extends LinterBase; + includeAdditionalFiles: boolean; + additionalFileIgnorePatterns: string[]; } function checkFix(taskSession: IHeftTaskSession, pluginOptions?: ILintPluginOptions): boolean { @@ -134,6 +136,13 @@ export default class LintPlugin implements IHeftTaskPlugin { } // Run the linters to completion. Linters emit errors and warnings to the logger. + const additionalFileIgnorePatterns: string[] = this.#getTypeScriptOutputIgnorePatterns( + heftConfiguration, + typescriptChangedFiles.map( + ([tsProgram]: [IExtendedProgram, ReadonlySet]) => tsProgram + ) + ); + let includeAdditionalFiles: boolean = true; for (const [tsProgram, changedFiles] of typescriptChangedFiles) { try { await this.#lintAsync({ @@ -142,13 +151,17 @@ export default class LintPlugin implements IHeftTaskPlugin { tsProgram, changedFiles, fix, - sarifLogPath + sarifLogPath, + includeAdditionalFiles, + additionalFileIgnorePatterns }); } catch (error) { if (!(error instanceof AlreadyReportedError)) { taskSession.logger.emitError(error as Error); } } + + includeAdditionalFiles = false; } // Clear the changed files so that we don't lint them again if the task is executed again @@ -222,13 +235,22 @@ export default class LintPlugin implements IHeftTaskPlugin { } async #lintAsync(options: ILintOptions): Promise { - const { taskSession, heftConfiguration, tsProgram, changedFiles, fix, sarifLogPath } = options; + const { + taskSession, + heftConfiguration, + tsProgram, + changedFiles, + fix, + sarifLogPath, + includeAdditionalFiles, + additionalFileIgnorePatterns + } = options; // Ensure that we have initialized. This promise is cached, so calling init // multiple times will only init once. await this.#ensureInitializedAsync(taskSession, heftConfiguration); - const linters: LinterBase[] = []; + const lintOperations: (() => Promise)[] = []; if (this.#eslintConfigFilePath && this.#eslintToolPath) { const eslintLinter: Eslint = await Eslint.initializeAsync({ tsProgram, @@ -238,9 +260,13 @@ export default class LintPlugin implements IHeftTaskPlugin { linterToolPath: this.#eslintToolPath, linterConfigFilePath: this.#eslintConfigFilePath, buildFolderPath: heftConfiguration.buildFolderPath, - buildMetadataFolderPath: taskSession.tempFolderPath + buildMetadataFolderPath: taskSession.tempFolderPath, + additionalFileIgnorePatterns }); - linters.push(eslintLinter); + const additionalFiles: ReadonlySet | undefined = includeAdditionalFiles + ? await eslintLinter.getAdditionalLintFilesAsync() + : undefined; + lintOperations.push(() => this.#runLinterAsync(eslintLinter, tsProgram, changedFiles, additionalFiles)); } if (this.#tslintConfigFilePath && this.#tslintToolPath) { @@ -253,17 +279,18 @@ export default class LintPlugin implements IHeftTaskPlugin { buildFolderPath: heftConfiguration.buildFolderPath, buildMetadataFolderPath: taskSession.tempFolderPath }); - linters.push(tslintLinter); + lintOperations.push(() => this.#runLinterAsync(tslintLinter, tsProgram, changedFiles)); } // Now that we know we have initialized properly, run the linter(s) - await Promise.all(linters.map((linter) => this.#runLinterAsync(linter, tsProgram, changedFiles))); + await Promise.all(lintOperations.map((lintOperation) => lintOperation())); } - async #runLinterAsync( - linter: LinterBase, + async #runLinterAsync( + linter: LinterBase, tsProgram: IExtendedProgram, - changedFiles?: ReadonlySet | undefined + changedFiles?: ReadonlySet | undefined, + additionalFiles?: ReadonlySet | undefined ): Promise { linter.printVersionHeader(); @@ -271,7 +298,38 @@ export default class LintPlugin implements IHeftTaskPlugin { await linter.performLintingAsync({ tsProgram, typeScriptFilenames, - changedFiles: changedFiles || new Set(tsProgram.getSourceFiles()) + changedFiles: changedFiles || new Set(tsProgram.getSourceFiles()), + additionalFiles }); } + + #getTypeScriptOutputIgnorePatterns( + heftConfiguration: HeftConfiguration, + tsPrograms: IExtendedProgram[] + ): string[] { + const { buildFolderPath } = heftConfiguration; + const outputFolderPaths: Set = new Set(); + for (const tsProgram of tsPrograms) { + const { outDir, declarationDir } = tsProgram.getCompilerOptions(); + if (outDir) { + outputFolderPaths.add(path.resolve(buildFolderPath, outDir)); + } + + if (declarationDir) { + outputFolderPaths.add(path.resolve(buildFolderPath, declarationDir)); + } + } + + const ignorePatterns: string[] = []; + for (const outputFolderPath of outputFolderPaths) { + // Only output folders under the project folder can be expressed as ESLint ignore patterns. + if (Path.isUnder(outputFolderPath, buildFolderPath)) { + ignorePatterns.push( + `${Path.convertToSlashes(outputFolderPath.slice(buildFolderPath.length + 1))}/**` + ); + } + } + + return ignorePatterns; + } } diff --git a/heft-plugins/heft-lint-plugin/src/LinterBase.ts b/heft-plugins/heft-lint-plugin/src/LinterBase.ts index 6e3404e5433..6be70889f92 100644 --- a/heft-plugins/heft-lint-plugin/src/LinterBase.ts +++ b/heft-plugins/heft-lint-plugin/src/LinterBase.ts @@ -25,9 +25,17 @@ export interface ILinterBaseOptions { tsProgram: IExtendedProgram; fix?: boolean; sarifLogPath?: string; + additionalFileIgnorePatterns?: string[]; } -export interface IRunLinterOptions { +export interface IAdditionalLintFile { + kind: 'additional'; + fileName: string; + text: string; + version: string; +} + +export interface IRunLinterOptions { tsProgram: IExtendedProgram; /** @@ -39,6 +47,11 @@ export interface IRunLinterOptions { * The set of files that TypeScript has compiled since the last compilation. */ changedFiles: ReadonlySet; + + /** + * Files selected by the linter configuration that are not part of the TypeScript program. + */ + additionalFiles?: ReadonlySet; } interface ILinterCacheData { @@ -61,7 +74,7 @@ interface ILinterCacheData { filesHash?: string; } -export abstract class LinterBase { +export abstract class LinterBase { protected readonly _scopedLogger: IScopedLogger; protected readonly _terminal: ITerminal; protected readonly _buildFolderPath: string; @@ -85,7 +98,7 @@ export abstract class LinterBase { public abstract printVersionHeader(): void; - public async performLintingAsync(options: IRunLinterOptions): Promise { + public async performLintingAsync(options: IRunLinterOptions): Promise { const startTime: number = performance.now(); let fileCount: number = 0; @@ -95,7 +108,12 @@ export abstract class LinterBase { // Collect and sort file paths for stable hashing const relativePathsArray: string[] = []; - for (const file of options.typeScriptFilenames) { + const lintFilenames: Set = new Set(options.typeScriptFilenames); + for (const additionalFile of options.additionalFiles || []) { + lintFilenames.add(additionalFile.fileName); + } + + for (const file of lintFilenames) { // Need to use relative paths to ensure portability. const relative: string = Path.convertToSlashes(path.relative(commonDirectory, file)); relativePaths.set(file, relative); @@ -167,7 +185,14 @@ export abstract class LinterBase { // https://github.com/palantir/tslint/blob/24d29e421828348f616bf761adb3892bcdf51662/src/linter.ts#L161-L179 // Modified to only lint files that have changed and that we care about const lintResults: TLintResult[] = []; - for (const sourceFile of options.tsProgram.getSourceFiles()) { + const sourceFiles: (IExtendedSourceFile | TAdditionalLintFile)[] = [ + ...options.tsProgram.getSourceFiles(), + ...(options.additionalFiles || []) + ]; + const changedFilePaths: Set = new Set( + Array.from(options.changedFiles, (sourceFile: IExtendedSourceFile) => sourceFile.fileName) + ); + for (const sourceFile of sourceFiles) { const filePath: string = sourceFile.fileName; const relative: string | undefined = relativePaths.get(filePath); @@ -181,7 +206,7 @@ export abstract class LinterBase { cachedVersion === '' || version === '' || cachedVersion !== version || - options.changedFiles.has(sourceFile) + changedFilePaths.has(filePath) ) { fileCount++; const results: TLintResult[] = await this.lintFileAsync(sourceFile); @@ -219,7 +244,9 @@ export abstract class LinterBase { this._terminal.writeVerboseLine(`Lint: ${duration}ms (${fileCount} files)`); } - protected async getSourceFileHashAsync(sourceFile: IExtendedSourceFile): Promise { + protected async getSourceFileHashAsync( + sourceFile: IExtendedSourceFile | TAdditionalLintFile + ): Promise { // TypeScript only computes the version during an incremental build. let version: string = sourceFile.version; if (!version) { @@ -234,7 +261,9 @@ export abstract class LinterBase { protected abstract getCacheVersionAsync(): Promise; - protected abstract lintFileAsync(sourceFile: IExtendedSourceFile): Promise; + protected abstract lintFileAsync( + sourceFile: IExtendedSourceFile | TAdditionalLintFile + ): Promise; protected abstract lintingFinishedAsync(lintResults: TLintResult[]): Promise;