Skip to content

Commit 9f22899

Browse files
NEW @W-20485780@ - Add Fixes and Suggestions support in eslint (#441)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 9be60ec commit 9f22899

12 files changed

Lines changed: 606 additions & 172 deletions

packages/code-analyzer-eslint-engine/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@salesforce/code-analyzer-eslint-engine",
33
"description": "Plugin package that adds 'eslint' as an engine into Salesforce Code Analyzer",
4-
"version": "0.41.0",
4+
"version": "0.42.0-SNAPSHOT",
55
"author": "The Salesforce Code Analyzer Team",
66
"license": "BSD-3-Clause",
77
"homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview",

packages/code-analyzer-eslint-engine/src/engine.ts

Lines changed: 100 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ import {
66
Engine,
77
EngineRunResults,
88
EventType,
9+
Fix,
910
LogLevel,
1011
RuleDescription,
1112
RunOptions,
13+
Suggestion,
1214
SeverityLevel,
1315
Violation,
1416
Workspace,
1517
} from '@salesforce/code-analyzer-engine-api'
16-
import {ESLint, Linter} from "eslint";
18+
import {ESLint, Linter, Rule} from "eslint";
1719
import {RulesMeta} from "@eslint/core";
1820
import {ESLintEngineConfig} from "./config";
1921
import {UserConfigInfo, UserConfigState} from "./user-config-info";
@@ -136,26 +138,40 @@ export class ESLintEngine extends Engine {
136138
rulesToRun: ruleNames,
137139
engineConfig: this.engineConfig,
138140
eslintContext: context,
139-
progressRange: [30, 95] // 30% to 95%
141+
progressRange: [30, 95], // 30% to 95%
142+
includeFixes: runOptions.includeFixes,
143+
includeSuggestions: runOptions.includeSuggestions
140144
}
141145
const lintResults: ESLint.LintResult[] = await this._runESLintWorkerTask.run(runTaskInput, runOptions.workingFolder);
142146

143147
const engineResults: EngineRunResults = {
144-
violations: this.toViolations(lintResults, new Set(ruleNames))
148+
violations: await this.toViolations(lintResults, new Set(ruleNames),
149+
runOptions.includeFixes ?? false, runOptions.includeSuggestions ?? false)
145150
};
146151
this.emitRunRulesProgressEvent(100);
147152
return engineResults;
148153
}
149154

150-
private toViolations(eslintResults: ESLint.LintResult[], specifiedRules: Set<string>): Violation[] {
155+
private async toViolations(eslintResults: ESLint.LintResult[], specifiedRules: Set<string>,
156+
includeFixes: boolean, includeSuggestions: boolean): Promise<Violation[]> {
151157
const violations: Violation[] = [];
152158
for (const eslintResult of eslintResults) {
159+
let lineStartOffsets: number[] | undefined;
153160
for (const resultMsg of eslintResult.messages) {
154161
if (!resultMsg.ruleId) { // If there is no ruleName, this is how ESLint indicates something else went wrong (like a parse error).
155162
this.handleEslintErrorOrWarning(eslintResult.filePath, resultMsg);
156163
continue;
157164
}
158-
const violation: Violation = toViolation(eslintResult.filePath, resultMsg);
165+
166+
const needsFileContent = (includeFixes && resultMsg.fix) ||
167+
(includeSuggestions && resultMsg.suggestions?.length);
168+
if (needsFileContent && !lineStartOffsets) {
169+
const source = eslintResult.source ?? await fs.readFile(eslintResult.filePath, 'utf8');
170+
lineStartOffsets = computeLineStartOffsets(source);
171+
}
172+
173+
const violation: Violation = toViolation(eslintResult.filePath, resultMsg,
174+
includeFixes, includeSuggestions, lineStartOffsets);
159175

160176
if (specifiedRules.has(violation.ruleName)) {
161177
violations.push(violation);
@@ -219,6 +235,10 @@ function toRuleDescription(ruleName: string, metadata: RulesMeta, status: ESLint
219235
if (ruleUrl && ruleUrl.includes("://git.soma")) {
220236
ruleUrl = undefined;
221237
}
238+
if (metadata.fixable) {
239+
tags = [...tags, 'Fixable'];
240+
}
241+
222242
return {
223243
name: ruleName,
224244
severityLevel: severityLevel,
@@ -260,11 +280,10 @@ function toTagsForCustomRule(metadata: RulesMeta): string[] {
260280
}
261281

262282

263-
function toViolation(file: string, resultMsg: Linter.LintMessage): Violation {
264-
// Note: If in the future we add in some sort of suggestion or fix field on Violation, then we might want to
265-
// leverage the fix and/or suggestions field on the LintMessage object.
266-
// See: https://eslint.org/docs/v8.x/integrate/nodejs-api#-lintmessage-type
267-
return {
283+
function toViolation(file: string, resultMsg: Linter.LintMessage,
284+
includeFixes: boolean, includeSuggestions: boolean,
285+
lineStartOffsets?: number[]): Violation {
286+
const violation: Violation = {
268287
ruleName: resultMsg.ruleId as string,
269288
message: resultMsg.message,
270289
codeLocations: [{
@@ -276,6 +295,77 @@ function toViolation(file: string, resultMsg: Linter.LintMessage): Violation {
276295
}],
277296
primaryLocationIndex: 0
278297
};
298+
299+
if (!lineStartOffsets) {
300+
return violation;
301+
}
302+
if (includeFixes && resultMsg.fix) {
303+
violation.fixes = [convertEslintFix(file, resultMsg.fix, lineStartOffsets)];
304+
}
305+
if (includeSuggestions && resultMsg.suggestions?.length) {
306+
violation.suggestions = resultMsg.suggestions.map(s =>
307+
convertEslintSuggestion(file, s, lineStartOffsets));
308+
}
309+
310+
return violation;
311+
}
312+
313+
function computeLineStartOffsets(fileContent: string): number[] {
314+
const offsets: number[] = [0];
315+
for (let i = 0; i < fileContent.length; i++) {
316+
if (fileContent[i] === '\n') {
317+
offsets.push(i + 1);
318+
}
319+
}
320+
return offsets;
321+
}
322+
323+
function indexToLineColumn(index: number, lineStartOffsets: number[]): { line: number, column: number } {
324+
// Binary search for the line containing this index
325+
let low = 0;
326+
let high = lineStartOffsets.length - 1;
327+
while (low < high) {
328+
const mid = Math.ceil((low + high + 1) / 2);
329+
if (mid >= lineStartOffsets.length || lineStartOffsets[mid] > index) {
330+
high = mid - 1;
331+
} else {
332+
low = mid;
333+
}
334+
}
335+
return {
336+
line: low + 1, // 1-based
337+
column: index - lineStartOffsets[low] + 1 // 1-based
338+
};
339+
}
340+
341+
function convertEslintFix(file: string, eslintFix: Rule.Fix, lineStartOffsets: number[]): Fix {
342+
const start = indexToLineColumn(eslintFix.range[0], lineStartOffsets);
343+
const end = indexToLineColumn(eslintFix.range[1], lineStartOffsets);
344+
return {
345+
location: {
346+
file: file,
347+
startLine: start.line,
348+
startColumn: start.column,
349+
endLine: end.line,
350+
endColumn: end.column
351+
},
352+
fixedCode: eslintFix.text
353+
};
354+
}
355+
356+
function convertEslintSuggestion(file: string, eslintSuggestion: Linter.LintSuggestion, lineStartOffsets: number[]): Suggestion {
357+
const start = indexToLineColumn(eslintSuggestion.fix.range[0], lineStartOffsets);
358+
const end = indexToLineColumn(eslintSuggestion.fix.range[1], lineStartOffsets);
359+
return {
360+
location: {
361+
file: file,
362+
startLine: start.line,
363+
startColumn: start.column,
364+
endLine: end.line,
365+
endColumn: end.column
366+
},
367+
message: eslintSuggestion.desc
368+
};
279369
}
280370

281371
function normalizeStartValue(startValue: number): number {

packages/code-analyzer-eslint-engine/src/run-eslint-worker-task.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ export type RunESLintWorkerTaskInput = {
1010
rulesToRun: string[]
1111
engineConfig: ESLintEngineConfig,
1212
eslintContext: ESLintContext,
13-
progressRange: [number, number]
13+
progressRange: [number, number],
14+
includeFixes?: boolean,
15+
includeSuggestions?: boolean
1416
}
1517

1618
export class RunESLintWorkerTask extends WorkerTask<RunESLintWorkerTaskInput, ESLint.LintResult[]> {

packages/code-analyzer-eslint-engine/test/engine.test.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const workspaceWithNoCustomConfig: string = path.join(testDataFolder, 'workspace
3434
const workspaceThatHasCustomConfigModifyingExistingRules: string = path.join(testDataFolder, 'workspaceWithFlatConfigCjs');
3535
const workspaceThatHasCustomConfigWithNewRules: string = path.join(testDataFolder, 'workspaceWithFlatConfigWithNewRules');
3636
const workspaceWithReactFiles: string = path.join(testDataFolder, 'workspaceWithReactFiles');
37+
const workspaceWithFixableViolations: string = path.join(testDataFolder, 'workspace_FixableViolations');
3738

3839
let original_working_directory: string;
3940
beforeAll(() => {
@@ -1009,6 +1010,180 @@ describe('Tests for emitting events', () => {
10091010
});
10101011
});
10111012

1013+
describe('Tests for Fixable tag on rule descriptions', () => {
1014+
it('When a rule has fixable metadata, the Fixable tag is included in its description', async () => {
1015+
const engine: Engine = await createEngineFromPlugin(DEFAULT_CONFIG_FOR_TESTING);
1016+
const describeOptions = createDescribeOptions(new Workspace('id', [workspaceWithNoCustomConfig]));
1017+
const ruleDescriptions: RuleDescription[] = await engine.describeRules(describeOptions);
1018+
1019+
const preferConstRule = ruleDescriptions.find(r => r.name === 'prefer-const');
1020+
expect(preferConstRule).toBeDefined();
1021+
expect(preferConstRule!.tags).toContain('Fixable');
1022+
});
1023+
1024+
it('When a rule does not have fixable metadata, the Fixable tag is not present', async () => {
1025+
const engine: Engine = await createEngineFromPlugin(DEFAULT_CONFIG_FOR_TESTING);
1026+
const describeOptions = createDescribeOptions(new Workspace('id', [workspaceWithNoCustomConfig]));
1027+
const ruleDescriptions: RuleDescription[] = await engine.describeRules(describeOptions);
1028+
1029+
const noInvalidRegexpRule = ruleDescriptions.find(r => r.name === 'no-invalid-regexp');
1030+
expect(noInvalidRegexpRule).toBeDefined();
1031+
expect(noInvalidRegexpRule!.tags).not.toContain('Fixable');
1032+
});
1033+
});
1034+
1035+
describe('Tests for fixes and suggestions in runRules', () => {
1036+
const fixableFile: string = path.join(workspaceWithFixableViolations, 'fixable.js');
1037+
1038+
it('When includeFixes is true, violations from fixable rules include fixes', async () => {
1039+
const engine: Engine = await createEngineFromPlugin(DEFAULT_CONFIG_FOR_TESTING);
1040+
const runOptions: RunOptions = {
1041+
...createRunOptions(new Workspace('id', [workspaceWithFixableViolations], [fixableFile])),
1042+
includeFixes: true
1043+
};
1044+
const results: EngineRunResults = await engine.runRules(['prefer-const'], runOptions);
1045+
1046+
expect(results.violations.length).toBeGreaterThanOrEqual(1);
1047+
const violationWithFix = results.violations.find(v => v.fixes && v.fixes.length > 0);
1048+
expect(violationWithFix).toBeDefined();
1049+
1050+
const fix = violationWithFix!.fixes![0];
1051+
expect(fix.fixedCode).toBeDefined();
1052+
expect(fix.location.file).toEqual(fixableFile);
1053+
expect(fix.location.startLine).toBeGreaterThanOrEqual(1);
1054+
expect(fix.location.startColumn).toBeGreaterThanOrEqual(1);
1055+
expect(fix.location.endLine).toBeGreaterThanOrEqual(fix.location.startLine);
1056+
});
1057+
1058+
it('When includeFixes is false, violations do not include fixes even for fixable rules', async () => {
1059+
const engine: Engine = await createEngineFromPlugin(DEFAULT_CONFIG_FOR_TESTING);
1060+
const runOptions: RunOptions = {
1061+
...createRunOptions(new Workspace('id', [workspaceWithFixableViolations], [fixableFile])),
1062+
includeFixes: false
1063+
};
1064+
const results: EngineRunResults = await engine.runRules(['prefer-const'], runOptions);
1065+
1066+
expect(results.violations.length).toBeGreaterThanOrEqual(1);
1067+
for (const violation of results.violations) {
1068+
expect(violation.fixes).toBeUndefined();
1069+
}
1070+
});
1071+
1072+
it('When includeFixes is not specified, violations do not include fixes', async () => {
1073+
const engine: Engine = await createEngineFromPlugin(DEFAULT_CONFIG_FOR_TESTING);
1074+
const runOptions: RunOptions = createRunOptions(new Workspace('id', [workspaceWithFixableViolations], [fixableFile]));
1075+
const results: EngineRunResults = await engine.runRules(['prefer-const'], runOptions);
1076+
1077+
expect(results.violations.length).toBeGreaterThanOrEqual(1);
1078+
for (const violation of results.violations) {
1079+
expect(violation.fixes).toBeUndefined();
1080+
}
1081+
});
1082+
1083+
it('When includeSuggestions is true, violations with suggestions include them', async () => {
1084+
const engine: Engine = await createEngineFromPlugin(DEFAULT_CONFIG_FOR_TESTING);
1085+
const runOptions: RunOptions = {
1086+
...createRunOptions(new Workspace('id', [workspaceWithFixableViolations], [fixableFile])),
1087+
includeSuggestions: true
1088+
};
1089+
const results: EngineRunResults = await engine.runRules(['no-unused-vars'], runOptions);
1090+
1091+
const violationsWithSuggestions = results.violations.filter(v => v.suggestions && v.suggestions.length > 0);
1092+
for (const violation of violationsWithSuggestions) {
1093+
for (const suggestion of violation.suggestions!) {
1094+
expect(suggestion.message).toBeDefined();
1095+
expect(suggestion.location.file).toEqual(fixableFile);
1096+
expect(suggestion.location.startLine).toBeGreaterThanOrEqual(1);
1097+
expect(suggestion.location.startColumn).toBeGreaterThanOrEqual(1);
1098+
}
1099+
}
1100+
});
1101+
1102+
it('When includeSuggestions is false, violations do not include suggestions', async () => {
1103+
const engine: Engine = await createEngineFromPlugin(DEFAULT_CONFIG_FOR_TESTING);
1104+
const runOptions: RunOptions = {
1105+
...createRunOptions(new Workspace('id', [workspaceWithFixableViolations], [fixableFile])),
1106+
includeSuggestions: false
1107+
};
1108+
const results: EngineRunResults = await engine.runRules(['no-unused-vars'], runOptions);
1109+
1110+
for (const violation of results.violations) {
1111+
expect(violation.suggestions).toBeUndefined();
1112+
}
1113+
});
1114+
1115+
it('When both includeFixes and includeSuggestions are true, both are populated where applicable', async () => {
1116+
const engine: Engine = await createEngineFromPlugin(DEFAULT_CONFIG_FOR_TESTING);
1117+
const runOptions: RunOptions = {
1118+
...createRunOptions(new Workspace('id', [workspaceWithFixableViolations], [fixableFile])),
1119+
includeFixes: true,
1120+
includeSuggestions: true
1121+
};
1122+
const results: EngineRunResults = await engine.runRules(['prefer-const', 'no-unused-vars'], runOptions);
1123+
1124+
expect(results.violations.length).toBeGreaterThanOrEqual(1);
1125+
const hasAnyFixes = results.violations.some(v => v.fixes && v.fixes.length > 0);
1126+
expect(hasAnyFixes).toBe(true);
1127+
});
1128+
1129+
it('Fix locations have correct line and column values converted from byte offsets', async () => {
1130+
const engine: Engine = await createEngineFromPlugin(DEFAULT_CONFIG_FOR_TESTING);
1131+
const runOptions: RunOptions = {
1132+
...createRunOptions(new Workspace('id', [workspaceWithFixableViolations], [fixableFile])),
1133+
includeFixes: true
1134+
};
1135+
const results: EngineRunResults = await engine.runRules(['prefer-const'], runOptions);
1136+
1137+
const preferConstViolation = results.violations.find(v => v.ruleName === 'prefer-const');
1138+
expect(preferConstViolation).toBeDefined();
1139+
expect(preferConstViolation!.fixes).toBeDefined();
1140+
1141+
const fix = preferConstViolation!.fixes![0];
1142+
expect(fix.location.startLine).toEqual(1);
1143+
expect(fix.location.startColumn).toBeGreaterThanOrEqual(1);
1144+
expect(fix.fixedCode).toContain('const');
1145+
});
1146+
1147+
it('Multiple violations in the same file produce fixes without errors', async () => {
1148+
const engine: Engine = await createEngineFromPlugin(DEFAULT_CONFIG_FOR_TESTING);
1149+
const runOptions: RunOptions = {
1150+
...createRunOptions(new Workspace('id', [workspaceWithFixableViolations], [fixableFile])),
1151+
includeFixes: true
1152+
};
1153+
const results: EngineRunResults = await engine.runRules(['prefer-const', 'no-var'], runOptions);
1154+
1155+
const fixableViolations = results.violations.filter(v => v.fixes && v.fixes.length > 0);
1156+
expect(fixableViolations.length).toBeGreaterThanOrEqual(1);
1157+
for (const violation of fixableViolations) {
1158+
expect(violation.fixes![0].location.file).toEqual(fixableFile);
1159+
}
1160+
});
1161+
1162+
it('Fix locations are correct for files with multi-byte characters', async () => {
1163+
const multibyteFile: string = path.join(workspaceWithFixableViolations, 'fixable_multibyte.js');
1164+
const engine: Engine = await createEngineFromPlugin(DEFAULT_CONFIG_FOR_TESTING);
1165+
const runOptions: RunOptions = {
1166+
...createRunOptions(new Workspace('id', [workspaceWithFixableViolations], [multibyteFile])),
1167+
includeFixes: true
1168+
};
1169+
const results: EngineRunResults = await engine.runRules(['prefer-const'], runOptions);
1170+
1171+
expect(results.violations.length).toBeGreaterThanOrEqual(1);
1172+
const violationWithFix = results.violations.find(v => v.fixes && v.fixes.length > 0);
1173+
expect(violationWithFix).toBeDefined();
1174+
1175+
const fix = violationWithFix!.fixes![0];
1176+
// Fix should target line 1 (let greeting) and replace with const
1177+
expect(fix.location.file).toEqual(multibyteFile);
1178+
expect(fix.location.startLine).toEqual(1);
1179+
expect(fix.fixedCode).toContain('const');
1180+
// Verify the line/column values are valid positive integers
1181+
expect(fix.location.startColumn).toBeGreaterThanOrEqual(1);
1182+
expect(fix.location.endLine).toBeGreaterThanOrEqual(1);
1183+
expect(fix.location.endColumn).toBeGreaterThanOrEqual(1);
1184+
});
1185+
});
1186+
10121187
function loadRuleDescriptions(fileNameFromTestDataFolder: string): RuleDescription[] {
10131188
return JSON.parse(fs.readFileSync(path.join(testDataFolder,
10141189
fileNameFromTestDataFolder), 'utf8')) as RuleDescription[];

0 commit comments

Comments
 (0)