Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@rushstack/heft-lint-plugin",
"comment": "Fix bug where TypeScript program is not reused in ESLint 9.",
"type": "patch"
}
],
"packageName": "@rushstack/heft-lint-plugin"
}
69 changes: 30 additions & 39 deletions heft-plugins/heft-lint-plugin/src/Eslint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { HeftConfiguration } from '@rushstack/heft';

import { LinterBase, type ILinterBaseOptions } from './LinterBase';
import type { IExtendedSourceFile } from './internalTypings/TypeScriptInternals';
import { name as pluginName, version as pluginVersion } from '../package.json';

interface IEslintOptions extends ILinterBaseOptions {
eslintPackage: typeof TEslint | typeof TEslintLegacy;
Expand Down Expand Up @@ -61,32 +62,13 @@ function getFormattedErrorMessage(
return lintMessage.ruleId ? `(${lintMessage.ruleId}) ${lintMessage.message}` : lintMessage.message;
}

interface IExtendedEslintConfig extends TEslint.Linter.Config {
// https://github.com/eslint/eslint/blob/d6fa4ac031c2fe24fb778e84940393fbda3ddf77/lib/config/config.js#L264
toJSON: () => object;
__originalToJSON: () => object;
}

function patchedToJSON(this: IExtendedEslintConfig): object {
// If the input config has a parserOptions.programs property, we need to recreate it
// as a non-enumerable property so that it does not get serialized, as it is not
// serializable.
if (
this.languageOptions?.parserOptions?.programs &&
this.languageOptions.parserOptions.propertyIsEnumerable('programs')
) {
let { programs } = this.languageOptions.parserOptions;
Object.defineProperty(this.languageOptions.parserOptions, 'programs', {
get: () => programs,
set: (value: TTypescript.Program[]) => {
programs = value;
},
enumerable: false
});
}

const serializableConfig: object = this.__originalToJSON.call(this);
return serializableConfig;
function parserOptionsToJson(this: TEslint.Linter.LanguageOptions['parserOptions']): object {
const serializableParserOptions: TEslint.Linter.LanguageOptions['parserOptions'] = {
...this,
// Remove the programs to avoid circular references and non-serializable data
programs: undefined
};
return serializableParserOptions;
}

const ESLINT_CONFIG_JS_FILENAME: string = 'eslint.config.js';
Expand All @@ -112,6 +94,7 @@ export class Eslint extends LinterBase<TEslint.ESLint.LintResult | TEslintLegacy
(TEslint.Linter.LintMessage | TEslintLegacy.Linter.LintMessage)[]
> = new Map();
private readonly _sarifLogPath: string | undefined;
private readonly _configHashMap: WeakMap<object, string> = new WeakMap();

protected constructor(options: IEslintOptions) {
super('eslint', options);
Expand Down Expand Up @@ -165,21 +148,37 @@ export class Eslint extends LinterBase<TEslint.ESLint.LintResult | TEslintLegacy
// if we're not fixing.
const legacyEslintOverrideConfig: TEslintLegacy.Linter.Config = {
parserOptions: {
programs: [tsProgram]
programs: [tsProgram],
toJSON: parserOptionsToJson
}
};
overrideConfig = legacyEslintOverrideConfig;
} else {
let overrideParserOptions: TEslint.Linter.ParserOptions = {
programs: [tsProgram],
// Used by stableStringify and ESLint > 9.28.0
toJSON: parserOptionsToJson
};
if (this._eslintPackageVersion.minor < 28) {
overrideParserOptions = Object.defineProperties(overrideParserOptions, {
// Support for `toJSON` within languageOptions was added in ESLint 9.28.0
// This hack tells ESLint's `languageOptionsToJSON` function to replace the entire `parserOptions` object with `@rushstack/heft-lint-plugin@${version}`
meta: {
value: {
name: pluginName,
version: pluginVersion
}
}
});
}
// The @typescript-eslint/parser package allows providing an existing TypeScript program to avoid needing
// to reparse. However, fixers in ESLint run in multiple passes against the underlying code until the
// fix fully succeeds. This conflicts with providing an existing program as the code no longer maps to
// the provided program, producing garbage fix output. To avoid this, only provide the existing program
// if we're not fixing.
const eslintOverrideConfig: TEslint.Linter.Config = {
languageOptions: {
parserOptions: {
programs: [tsProgram]
}
parserOptions: overrideParserOptions
}
};
overrideConfig = eslintOverrideConfig;
Expand Down Expand Up @@ -254,18 +253,10 @@ export class Eslint extends LinterBase<TEslint.ESLint.LintResult | TEslintLegacy
}

protected override async getSourceFileHashAsync(sourceFile: IExtendedSourceFile): Promise<string> {
const sourceFileEslintConfiguration: IExtendedEslintConfig = await this._linter.calculateConfigForFile(
const sourceFileEslintConfiguration: TEslint.Linter.Config = await this._linter.calculateConfigForFile(
sourceFile.fileName
);

// The eslint configuration object contains a toJSON() method that returns a serializable version of the
// configuration. However, we are manually injecting the TypeScript program into the parserOptions, which
// is not serializable. Patch the function to remove the program before returning the serializable version.
if (sourceFileEslintConfiguration.toJSON && !sourceFileEslintConfiguration.__originalToJSON) {
sourceFileEslintConfiguration.__originalToJSON = sourceFileEslintConfiguration.toJSON;
sourceFileEslintConfiguration.toJSON = patchedToJSON.bind(sourceFileEslintConfiguration);
}

const hash: Hash = createHash('sha1');
// Use a stable stringifier to ensure that the hash is always the same, even if the order of the properties
// changes. This is also done in ESLint
Expand Down
Loading