Skip to content

Commit 01173c3

Browse files
D4N14Ldmichon-msft
andauthored
[heft-lint-plugin] Add ESLint and TSLint fixer support (#4886)
* Nit: docs and cleanup * Add fix functionality to tslint * Add support for --fix in ESLint * Rush change * Update heft-plugins/heft-lint-plugin/src/Eslint.ts Co-authored-by: David Michon <[email protected]> * Update heft-plugins/heft-lint-plugin/src/Eslint.ts --------- Co-authored-by: David Michon <[email protected]>
1 parent 8d69f29 commit 01173c3

File tree

8 files changed

+221
-69
lines changed

8 files changed

+221
-69
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/heft-lint-plugin",
5+
"comment": "Add autofix functionality for ESLint and TSLint. Fixes can now be applied by providing the \"--fix\" command-line argument, or setting the \"alwaysFix\" plugin option to \"true\"",
6+
"type": "minor"
7+
}
8+
],
9+
"packageName": "@rushstack/heft-lint-plugin"
10+
}

heft-plugins/heft-lint-plugin/heft-plugin.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,17 @@
44
"taskPlugins": [
55
{
66
"pluginName": "lint-plugin",
7-
"entryPoint": "./lib/LintPlugin"
7+
"entryPoint": "./lib/LintPlugin",
8+
"optionsSchema": "./lib/schemas/heft-lint-plugin.schema.json",
9+
10+
"parameterScope": "lint",
11+
"parameters": [
12+
{
13+
"longName": "--fix",
14+
"parameterKind": "flag",
15+
"description": "Fix all encountered rule violations where the violated rule provides a fixer."
16+
}
17+
]
818
}
919
]
1020
}

heft-plugins/heft-lint-plugin/src/Eslint.ts

Lines changed: 96 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
22
// See LICENSE in the project root for license information.
33

4-
import * as path from 'path';
54
import * as crypto from 'crypto';
65
import * as semver from 'semver';
76
import type * as TTypescript from 'typescript';
@@ -26,8 +25,10 @@ enum EslintMessageSeverity {
2625
error = 2
2726
}
2827

28+
// Patch the timer used to track rule execution time. This allows us to get access to the detailed information
29+
// about how long each rule took to execute, which we provide on the CLI when running in verbose mode.
2930
async function patchTimerAsync(eslintPackagePath: string, timingsMap: Map<string, number>): Promise<void> {
30-
const timingModulePath: string = path.join(eslintPackagePath, 'lib', 'linter', 'timing');
31+
const timingModulePath: string = `${eslintPackagePath}/lib/linter/timing`;
3132
const timing: IEslintTiming = (await import(timingModulePath)).default;
3233
timing.enabled = true;
3334
const patchedTime: (key: string, fn: (...args: unknown[]) => unknown) => (...args: unknown[]) => unknown = (
@@ -46,26 +47,55 @@ async function patchTimerAsync(eslintPackagePath: string, timingsMap: Map<string
4647
timing.time = patchedTime;
4748
}
4849

50+
function getFormattedErrorMessage(lintMessage: TEslint.Linter.LintMessage): string {
51+
// https://eslint.org/docs/developer-guide/nodejs-api#◆-lintmessage-type
52+
return lintMessage.ruleId ? `(${lintMessage.ruleId}) ${lintMessage.message}` : lintMessage.message;
53+
}
54+
4955
export class Eslint extends LinterBase<TEslint.ESLint.LintResult> {
5056
private readonly _eslintPackage: typeof TEslint;
5157
private readonly _linter: TEslint.ESLint;
5258
private readonly _eslintTimings: Map<string, number> = new Map();
59+
private readonly _currentFixMessages: TEslint.Linter.LintMessage[] = [];
60+
private readonly _fixMessagesByResult: Map<TEslint.ESLint.LintResult, TEslint.Linter.LintMessage[]> =
61+
new Map();
5362

5463
protected constructor(options: IEslintOptions) {
5564
super('eslint', options);
5665

57-
const { buildFolderPath, eslintPackage, linterConfigFilePath, tsProgram, eslintTimings } = options;
66+
const { buildFolderPath, eslintPackage, linterConfigFilePath, tsProgram, eslintTimings, fix } = options;
5867
this._eslintPackage = eslintPackage;
59-
this._linter = new eslintPackage.ESLint({
60-
cwd: buildFolderPath,
61-
overrideConfigFile: linterConfigFilePath,
62-
// Override config takes precedence over overrideConfigFile, which allows us to provide
63-
// the source TypeScript program to ESLint
64-
overrideConfig: {
68+
69+
let overrideConfig: TEslint.Linter.Config | undefined;
70+
let fixFn: Exclude<TEslint.ESLint.Options['fix'], boolean>;
71+
if (fix) {
72+
// We do not recieve the messages for the issues that were fixed, so we need to track them ourselves
73+
// so that we can log them after the fix is applied. This array will be populated by the fix function,
74+
// and subsequently mapped to the results in the ESLint.lintFileAsync method below. After the messages
75+
// are mapped, the array will be cleared so that it is ready for the next fix operation.
76+
fixFn = (message: TEslint.Linter.LintMessage) => {
77+
this._currentFixMessages.push(message);
78+
return true;
79+
};
80+
} else {
81+
// The @typescript-eslint/parser package allows providing an existing TypeScript program to avoid needing
82+
// to reparse. However, fixers in ESLint run in multiple passes against the underlying code until the
83+
// fix fully succeeds. This conflicts with providing an existing program as the code no longer maps to
84+
// the provided program, producing garbage fix output. To avoid this, only provide the existing program
85+
// if we're not fixing.
86+
overrideConfig = {
6587
parserOptions: {
6688
programs: [tsProgram]
6789
}
68-
}
90+
};
91+
}
92+
93+
this._linter = new eslintPackage.ESLint({
94+
cwd: buildFolderPath,
95+
overrideConfigFile: linterConfigFilePath,
96+
// Override config takes precedence over overrideConfigFile
97+
overrideConfig,
98+
fix: fixFn
6999
});
70100
this._eslintTimings = eslintTimings;
71101
}
@@ -121,17 +151,32 @@ export class Eslint extends LinterBase<TEslint.ESLint.LintResult> {
121151
filePath: sourceFile.fileName
122152
});
123153

154+
// Map the fix messages to the results. This API should only return one result per file, so we can be sure
155+
// that the fix messages belong to the returned result. If we somehow receive multiple results, we will
156+
// drop the messages on the floor, but since they are only used for logging, this should not be a problem.
157+
const fixMessages: TEslint.Linter.LintMessage[] = this._currentFixMessages.splice(0);
158+
if (lintResults.length === 1) {
159+
this._fixMessagesByResult.set(lintResults[0], fixMessages);
160+
}
161+
162+
this._fixesPossible =
163+
this._fixesPossible ||
164+
(!this._fix &&
165+
lintResults.some((lintResult: TEslint.ESLint.LintResult) => {
166+
return lintResult.fixableErrorCount + lintResult.fixableWarningCount > 0;
167+
}));
168+
124169
const failures: TEslint.ESLint.LintResult[] = [];
125170
for (const lintResult of lintResults) {
126-
if (lintResult.messages.length > 0) {
171+
if (lintResult.messages.length > 0 || lintResult.output) {
127172
failures.push(lintResult);
128173
}
129174
}
130175

131176
return failures;
132177
}
133178

134-
protected lintingFinished(lintFailures: TEslint.ESLint.LintResult[]): void {
179+
protected async lintingFinishedAsync(lintFailures: TEslint.ESLint.LintResult[]): Promise<void> {
135180
let omittedRuleCount: number = 0;
136181
const timings: [string, number][] = Array.from(this._eslintTimings).sort(
137182
(x: [string, number], y: [string, number]) => {
@@ -150,45 +195,58 @@ export class Eslint extends LinterBase<TEslint.ESLint.LintResult> {
150195
this._terminal.writeVerboseLine(`${omittedRuleCount} rules took 0ms`);
151196
}
152197

153-
const errors: Error[] = [];
154-
const warnings: Error[] = [];
155-
156-
for (const eslintFailure of lintFailures) {
157-
for (const message of eslintFailure.messages) {
158-
// https://eslint.org/docs/developer-guide/nodejs-api#◆-lintmessage-type
159-
const formattedMessage: string = message.ruleId
160-
? `(${message.ruleId}) ${message.message}`
161-
: message.message;
162-
const errorObject: FileError = new FileError(formattedMessage, {
163-
absolutePath: eslintFailure.filePath,
164-
projectFolder: this._buildFolderPath,
165-
line: message.line,
166-
column: message.column
167-
});
168-
switch (message.severity) {
198+
if (this._fix && this._fixMessagesByResult.size > 0) {
199+
await this._eslintPackage.ESLint.outputFixes(lintFailures);
200+
}
201+
202+
for (const lintFailure of lintFailures) {
203+
// Report linter fixes to the logger. These will only be returned when the underlying failure was fixed
204+
const fixMessages: TEslint.Linter.LintMessage[] | undefined =
205+
this._fixMessagesByResult.get(lintFailure);
206+
if (fixMessages) {
207+
for (const fixMessage of fixMessages) {
208+
const formattedMessage: string = `[FIXED] ${getFormattedErrorMessage(fixMessage)}`;
209+
const errorObject: FileError = this._getLintFileError(lintFailure, fixMessage, formattedMessage);
210+
this._scopedLogger.emitWarning(errorObject);
211+
}
212+
}
213+
214+
// Report linter errors and warnings to the logger
215+
for (const lintMessage of lintFailure.messages) {
216+
const errorObject: FileError = this._getLintFileError(lintFailure, lintMessage);
217+
switch (lintMessage.severity) {
169218
case EslintMessageSeverity.error: {
170-
errors.push(errorObject);
219+
this._scopedLogger.emitError(errorObject);
171220
break;
172221
}
173222

174223
case EslintMessageSeverity.warning: {
175-
warnings.push(errorObject);
224+
this._scopedLogger.emitWarning(errorObject);
176225
break;
177226
}
178227
}
179228
}
180229
}
181-
182-
for (const error of errors) {
183-
this._scopedLogger.emitError(error);
184-
}
185-
186-
for (const warning of warnings) {
187-
this._scopedLogger.emitWarning(warning);
188-
}
189230
}
190231

191232
protected async isFileExcludedAsync(filePath: string): Promise<boolean> {
192233
return await this._linter.isPathIgnored(filePath);
193234
}
235+
236+
private _getLintFileError(
237+
lintResult: TEslint.ESLint.LintResult,
238+
lintMessage: TEslint.Linter.LintMessage,
239+
message?: string
240+
): FileError {
241+
if (!message) {
242+
message = getFormattedErrorMessage(lintMessage);
243+
}
244+
245+
return new FileError(message, {
246+
absolutePath: lintResult.filePath,
247+
projectFolder: this._buildFolderPath,
248+
line: lintMessage.line,
249+
column: lintMessage.column
250+
});
251+
}
194252
}

heft-plugins/heft-lint-plugin/src/LintPlugin.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,23 @@ import type { IExtendedProgram, IExtendedSourceFile } from './internalTypings/Ty
2222

2323
const PLUGIN_NAME: 'lint-plugin' = 'lint-plugin';
2424
const TYPESCRIPT_PLUGIN_NAME: typeof TypeScriptPluginName = 'typescript-plugin';
25+
const FIX_PARAMETER_NAME: string = '--fix';
2526
const ESLINTRC_JS_FILENAME: string = '.eslintrc.js';
2627
const ESLINTRC_CJS_FILENAME: string = '.eslintrc.cjs';
2728

28-
export default class LintPlugin implements IHeftTaskPlugin {
29+
interface ILintPluginOptions {
30+
alwaysFix?: boolean;
31+
}
32+
33+
interface ILintOptions {
34+
taskSession: IHeftTaskSession;
35+
heftConfiguration: HeftConfiguration;
36+
tsProgram: IExtendedProgram;
37+
fix?: boolean;
38+
changedFiles?: ReadonlySet<IExtendedSourceFile>;
39+
}
40+
41+
export default class LintPlugin implements IHeftTaskPlugin<ILintPluginOptions> {
2942
private readonly _lintingPromises: Promise<void>[] = [];
3043

3144
// These are initliazed by _initAsync
@@ -35,7 +48,11 @@ export default class LintPlugin implements IHeftTaskPlugin {
3548
private _tslintToolPath: string | undefined;
3649
private _tslintConfigFilePath: string | undefined;
3750

38-
public apply(taskSession: IHeftTaskSession, heftConfiguration: HeftConfiguration): void {
51+
public apply(
52+
taskSession: IHeftTaskSession,
53+
heftConfiguration: HeftConfiguration,
54+
pluginOptions?: ILintPluginOptions
55+
): void {
3956
// Disable linting in watch mode. Some lint rules require the context of multiple files, which
4057
// may not be available in watch mode.
4158
if (!taskSession.parameters.watch) {
@@ -48,12 +65,15 @@ export default class LintPlugin implements IHeftTaskPlugin {
4865
accessor.onChangedFilesHook.tap(
4966
PLUGIN_NAME,
5067
(changedFilesHookOptions: IChangedFilesHookOptions) => {
51-
const lintingPromise: Promise<void> = this._lintAsync(
68+
const lintingPromise: Promise<void> = this._lintAsync({
5269
taskSession,
5370
heftConfiguration,
54-
changedFilesHookOptions.program as IExtendedProgram,
55-
changedFilesHookOptions.changedFiles as ReadonlySet<IExtendedSourceFile>
56-
);
71+
fix:
72+
pluginOptions?.alwaysFix ||
73+
taskSession.parameters.getFlagParameter(FIX_PARAMETER_NAME).value,
74+
tsProgram: changedFilesHookOptions.program as IExtendedProgram,
75+
changedFiles: changedFilesHookOptions.changedFiles as ReadonlySet<IExtendedSourceFile>
76+
});
5777
lintingPromise.catch(() => {
5878
// Suppress unhandled promise rejection error
5979
});
@@ -114,12 +134,9 @@ export default class LintPlugin implements IHeftTaskPlugin {
114134
}
115135
}
116136

117-
private async _lintAsync(
118-
taskSession: IHeftTaskSession,
119-
heftConfiguration: HeftConfiguration,
120-
tsProgram: IExtendedProgram,
121-
changedFiles?: ReadonlySet<IExtendedSourceFile>
122-
): Promise<void> {
137+
private async _lintAsync(options: ILintOptions): Promise<void> {
138+
const { taskSession, heftConfiguration, tsProgram, changedFiles, fix } = options;
139+
123140
// Ensure that we have initialized. This promise is cached, so calling init
124141
// multiple times will only init once.
125142
await this._ensureInitializedAsync(taskSession, heftConfiguration);
@@ -128,6 +145,7 @@ export default class LintPlugin implements IHeftTaskPlugin {
128145
if (this._eslintConfigFilePath && this._eslintToolPath) {
129146
const eslintLinter: Eslint = await Eslint.initializeAsync({
130147
tsProgram,
148+
fix,
131149
scopedLogger: taskSession.logger,
132150
linterToolPath: this._eslintToolPath,
133151
linterConfigFilePath: this._eslintConfigFilePath,
@@ -140,6 +158,7 @@ export default class LintPlugin implements IHeftTaskPlugin {
140158
if (this._tslintConfigFilePath && this._tslintToolPath) {
141159
const tslintLinter: Tslint = await Tslint.initializeAsync({
142160
tsProgram,
161+
fix,
143162
scopedLogger: taskSession.logger,
144163
linterToolPath: this._tslintToolPath,
145164
linterConfigFilePath: this._tslintConfigFilePath,

heft-plugins/heft-lint-plugin/src/LinterBase.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface ILinterBaseOptions {
2020
linterToolPath: string;
2121
linterConfigFilePath: string;
2222
tsProgram: IExtendedProgram;
23+
fix?: boolean;
2324
}
2425

2526
export interface IRunLinterOptions {
@@ -56,6 +57,9 @@ export abstract class LinterBase<TLintResult> {
5657
protected readonly _buildFolderPath: string;
5758
protected readonly _buildMetadataFolderPath: string;
5859
protected readonly _linterConfigFilePath: string;
60+
protected readonly _fix: boolean;
61+
62+
protected _fixesPossible: boolean = false;
5963

6064
private readonly _linterName: string;
6165

@@ -66,6 +70,7 @@ export abstract class LinterBase<TLintResult> {
6670
this._buildMetadataFolderPath = options.buildMetadataFolderPath;
6771
this._linterConfigFilePath = options.linterConfigFilePath;
6872
this._linterName = linterName;
73+
this._fix = options.fix || false;
6974
}
7075

7176
public abstract printVersionHeader(): void;
@@ -156,7 +161,13 @@ export abstract class LinterBase<TLintResult> {
156161
}
157162
//#endregion
158163

159-
this.lintingFinished(lintFailures);
164+
await this.lintingFinishedAsync(lintFailures);
165+
166+
if (!this._fix && this._fixesPossible) {
167+
this._terminal.writeWarningLine(
168+
'The linter reported that fixes are possible. To apply fixes, run Heft with the "--fix" option.'
169+
);
170+
}
160171

161172
const updatedTslintCacheData: ILinterCacheData = {
162173
cacheVersion: linterCacheVersion,
@@ -173,7 +184,7 @@ export abstract class LinterBase<TLintResult> {
173184

174185
protected abstract lintFileAsync(sourceFile: IExtendedSourceFile): Promise<TLintResult[]>;
175186

176-
protected abstract lintingFinished(lintFailures: TLintResult[]): void;
187+
protected abstract lintingFinishedAsync(lintFailures: TLintResult[]): Promise<void>;
177188

178189
protected abstract isFileExcludedAsync(filePath: string): Promise<boolean>;
179190
}

0 commit comments

Comments
 (0)