-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathengine.ts
More file actions
388 lines (344 loc) · 17.4 KB
/
Copy pathengine.ts
File metadata and controls
388 lines (344 loc) · 17.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
import * as fs from 'node:fs/promises';
import path from 'node:path';
import {
COMMON_TAGS,
DescribeOptions,
Engine,
EngineRunResults,
EventType,
Fix,
LogLevel,
RuleDescription,
RunOptions,
Suggestion,
SeverityLevel,
Violation,
Workspace,
} from '@salesforce/code-analyzer-engine-api'
import {ESLint, Linter, Rule} from "eslint";
import {RulesMeta} from "@eslint/core";
import {ESLintEngineConfig} from "./config";
import {UserConfigInfo, UserConfigState} from "./user-config-info";
import {getMessage} from "./messages";
import {ESLintWorkspace} from "./workspace";
import {RULE_MAPPINGS} from "./rule-mappings";
import {indent} from '@salesforce/code-analyzer-engine-api/utils';
import {RunESLintWorkerTask, RunESLintWorkerTaskInput} from "./run-eslint-worker-task";
import {ESLintContext, ESLintContextFactory, ESLintRuleStatus} from "./eslint-context";
export class ESLintEngine extends Engine {
static readonly NAME = "eslint";
// Keeping this public so that tests can modify the _runInCurrentThreadInsteadofNewThread property if needed
readonly _runESLintWorkerTask: RunESLintWorkerTask;
private readonly engineConfig: ESLintEngineConfig;
private readonly delegateV8Engine: Engine;
private readonly contextFactory: ESLintContextFactory;
private userConfigInfoCache: Map<string, UserConfigInfo> = new Map();
private eslintContextCache: Map<string, ESLintContext> = new Map();
constructor(engineConfig: ESLintEngineConfig, delegateV8Engine: Engine) {
super();
this.engineConfig = engineConfig;
this.delegateV8Engine = delegateV8Engine;
this.contextFactory = new ESLintContextFactory();
this._runESLintWorkerTask = new RunESLintWorkerTask();
for (const eventType of Object.values(EventType)) { // Forward events from composed classes
this.delegateV8Engine.onEvent(eventType, this.emitEvent.bind(this));
this.contextFactory.onEvent(eventType, this.emitEvent.bind(this));
this._runESLintWorkerTask.onEvent(eventType, this.emitEvent.bind(this));
}
}
getName(): string {
return ESLintEngine.NAME;
}
public async getEngineVersion(): Promise<string> {
const pathToPackageJson: string = path.join(__dirname, '..', 'package.json');
const packageJson: {version: string} = JSON.parse(await fs.readFile(pathToPackageJson, 'utf-8'));
return packageJson.version;
}
async describeRules(describeOptions: DescribeOptions): Promise<RuleDescription[]> {
this.emitDescribeRulesProgressEvent(0);
const userConfigInfo: UserConfigInfo = this.getUserConfigInfo(describeOptions.workspace);
this.emitLogEvent(LogLevel.Fine, `Detected the following state regarding the user's ESLint configuration: ${userConfigInfo}`);
if (userConfigInfo.getState() === UserConfigState.LEGACY_USER_CONFIG) {
this.emitLogEvent(LogLevel.Warn, getMessage('DetectedLegacyConfig',
userConfigInfo.getChosenUserConfigFile() ?? /* istanbul ignore next */ userConfigInfo.getChosenUserIgnoreFile()!));
this.emitTelemetryEvent('eslintLegacyConfigDetected', {
'eslint_engine_version': await this.getEngineVersion(),
'eslint8_engine_version': await this.delegateV8Engine.getEngineVersion()
});
return this.delegateV8Engine.describeRules(describeOptions);
}
if (userConfigInfo.getChosenUserConfigFile()) {
this.emitLogEvent(LogLevel.Debug, getMessage('ApplyingFlatConfigFile', userConfigInfo.getChosenUserConfigFile()!));
} else if (userConfigInfo.getDiscoveredConfigFile()) {
this.emitLogEvent(LogLevel.Info, getMessage('UnusedESLintConfigFile',
makeRelativeTo(process.cwd(), userConfigInfo.getDiscoveredConfigFile()!),
makeRelativeTo(this.engineConfig.config_root, userConfigInfo.getDiscoveredConfigFile()!)));
}
if (userConfigInfo.getChosenUserIgnoreFile()) {
this.emitLogEvent(LogLevel.Warn, getMessage('IgnoringLegacyIgnoreFile', userConfigInfo.getChosenUserIgnoreFile()!));
}
this.emitDescribeRulesProgressEvent(10);
const context: ESLintContext = await this.getESLintContext(describeOptions.workspace, (perc: number) => {
this.emitDescribeRulesProgressEvent(10 + 80*(perc/100)); // 10% to 90%
});
let ruleDescriptions: RuleDescription[] = [];
for (const [ruleName, ruleState] of Object.entries(context.ruleInfo)) {
// If a user's configuration has a rule explicitly turned off, then since we have no way of turning
// it back on in our final configuration (because we don't know what rule parameters to use) then we simply
// prevent these rules from showing up and being selectable.
if (ruleState.status === ESLintRuleStatus.OFF) {
continue;
}
// Only include rules that have metadata and that are not deprecated
if (ruleState.meta && !ruleState.meta.deprecated) {
ruleDescriptions.push(toRuleDescription(ruleName, ruleState.meta, ruleState.status));
}
}
this.emitDescribeRulesProgressEvent(95);
ruleDescriptions = ruleDescriptions.sort((d1, d2) => d1.name.localeCompare(d2.name));
this.emitDescribeRulesProgressEvent(100);
return ruleDescriptions;
}
async runRules(ruleNames: string[], runOptions: RunOptions): Promise<EngineRunResults> {
this.emitRunRulesProgressEvent(0);
const userConfigInfo: UserConfigInfo = this.getUserConfigInfo(runOptions.workspace);
if (userConfigInfo.getState() === UserConfigState.LEGACY_USER_CONFIG) {
return this.delegateV8Engine.runRules(ruleNames, runOptions);
}
const context: ESLintContext = await this.getESLintContext(runOptions.workspace, (perc: number) => {
this.emitRunRulesProgressEvent(30*(perc/100)); // 0% to 30%
});
if (context.filesToScan.length === 0) {
this.emitRunRulesProgressEvent(100);
return { violations: [] };
}
// The ESLint.lintFiles method can be expensive. It seems to run everything synchronously which can block
// the main node thread preventing the display from being updated with progress updates. To work around this
// we do the heavy lifting with a background worker thread. This requires the input/output to that thread to be
// serializable. After getting the ESLintContext (which is serializable) we now can just hand over the remaining
// pieces needed via a serializable RunESLintWorkerTaskInput and then execute.
const runTaskInput: RunESLintWorkerTaskInput = {
rulesToRun: ruleNames,
engineConfig: this.engineConfig,
eslintContext: context,
progressRange: [30, 95], // 30% to 95%
includeFixes: runOptions.includeFixes,
includeSuggestions: runOptions.includeSuggestions
}
const lintResults: ESLint.LintResult[] = await this._runESLintWorkerTask.run(runTaskInput, runOptions.workingFolder);
const engineResults: EngineRunResults = {
violations: await this.toViolations(lintResults, new Set(ruleNames),
runOptions.includeFixes ?? false, runOptions.includeSuggestions ?? false)
};
this.emitRunRulesProgressEvent(100);
return engineResults;
}
private async toViolations(eslintResults: ESLint.LintResult[], specifiedRules: Set<string>,
includeFixes: boolean, includeSuggestions: boolean): Promise<Violation[]> {
const violations: Violation[] = [];
for (const eslintResult of eslintResults) {
let lineStartOffsets: number[] | undefined;
for (const resultMsg of eslintResult.messages) {
if (!resultMsg.ruleId) { // If there is no ruleName, this is how ESLint indicates something else went wrong (like a parse error).
this.handleEslintErrorOrWarning(eslintResult.filePath, resultMsg);
continue;
}
const needsFileContent = (includeFixes && resultMsg.fix) ||
(includeSuggestions && resultMsg.suggestions?.length);
if (needsFileContent && !lineStartOffsets) {
const source = eslintResult.source ?? await fs.readFile(eslintResult.filePath, 'utf8');
lineStartOffsets = computeLineStartOffsets(source);
}
const violation: Violation = toViolation(eslintResult.filePath, resultMsg,
includeFixes, includeSuggestions, lineStartOffsets);
if (specifiedRules.has(violation.ruleName)) {
violations.push(violation);
} else {
// This may be possible if a user tries to suppress an eslint rule in their code that isn't available. We just ignore it but debug it just in case.
this.emitLogEvent(LogLevel.Debug, getMessage('ViolationFoundFromUnregisteredRule', violation.ruleName, JSON.stringify(violation,null,2)))
}
}
}
return violations;
}
private handleEslintErrorOrWarning(file: string, resultMsg: Linter.LintMessage) {
/* istanbul ignore else */
if (resultMsg.fatal) {
this.emitLogEvent(LogLevel.Error, getMessage('ESLintErroredWhenScanningFile', file, indent(resultMsg.message)));
} else {
this.emitLogEvent(LogLevel.Warn, getMessage('ESLintWarnedWhenScanningFile', file, indent(resultMsg.message)));
}
}
private getUserConfigInfo(workspace?: Workspace): UserConfigInfo {
const cacheKey: string = workspace?.getWorkspaceId() ?? process.cwd();
if (!this.userConfigInfoCache.has(cacheKey)) {
this.userConfigInfoCache.set(cacheKey, new UserConfigInfo(this.engineConfig, workspace));
}
return this.userConfigInfoCache.get(cacheKey)!;
}
private async getESLintContext(workspace: Workspace | undefined, emitProgress: (perc: number) => void): Promise<ESLintContext> {
const cacheKey: string = workspace?.getWorkspaceId() ?? process.cwd();
if (!this.eslintContextCache.has(cacheKey)) {
const userConfigInfo: UserConfigInfo = this.getUserConfigInfo(workspace);
const eslintWorkspace: ESLintWorkspace = ESLintWorkspace.from(workspace,
this.engineConfig.config_root, this.engineConfig.file_extensions, userConfigInfo.getChosenUserConfigFile());
const context: ESLintContext = await this.contextFactory.calculateESLintContext(this.engineConfig, eslintWorkspace,
userConfigInfo.getChosenUserConfigFile(), emitProgress);
this.eslintContextCache.set(cacheKey, context);
}
return this.eslintContextCache.get(cacheKey)!;
}
}
function toRuleDescription(ruleName: string, metadata: RulesMeta, status: ESLintRuleStatus): RuleDescription {
let severityLevel: SeverityLevel;
let tags: string[];
if (ruleName in RULE_MAPPINGS) {
severityLevel = RULE_MAPPINGS[ruleName].severity;
tags = RULE_MAPPINGS[ruleName].tags;
} else { // Any rule we don't know about from our RULE_MAPPINGS must be a custom rule. Unit tests prevent otherwise.
severityLevel = toSeverityLevelForCustomRule(metadata, status);
tags = toTagsForCustomRule(metadata);
}
let ruleUrl: string | undefined = metadata.docs?.url;
// Currently, each lwc-platform rule's url points to internal an internal git.soma repo which is not accessible
// to external users, so we remove them. See https://git.soma.salesforce.com/lwc/eslint-plugin-lwc-platform/issues/151
// TODO: Remove this check once the lwc-platform rules are fixed and we have updated our dependency
if (ruleUrl && ruleUrl.includes("://git.soma")) {
ruleUrl = undefined;
}
if (metadata.fixable) {
tags = [...tags, 'Fixable'];
}
return {
name: ruleName,
severityLevel: severityLevel,
tags: tags,
description: metadata.docs?.description || '',
resourceUrls: ruleUrl ? [ruleUrl] : []
}
}
function toSeverityLevelForCustomRule(metadata: RulesMeta, status: ESLintRuleStatus): SeverityLevel {
if (status === ESLintRuleStatus.WARN) {
// An ESLint "warn" status is what users typically use to inform them of things without labeling it as a
// violation to stop their build over. Our Info level severity is the closest to this.
return SeverityLevel.Info;
} else if (metadata.type === "problem") {
// The "problem" category is typically something users care most about, so we mark these as High severity.
return SeverityLevel.High;
} else if (metadata.type === "layout") {
// The "layout" category is more for cosmetic issues only, so we mark these as Low severity.
return SeverityLevel.Low;
}
// All else will give assigned Moderate. Recall that users may override these severities if they wish.
return SeverityLevel.Moderate;
}
function toTagsForCustomRule(metadata: RulesMeta): string[] {
const tags: string[] = [COMMON_TAGS.RECOMMENDED];
if (metadata.type === "layout") {
tags.push(COMMON_TAGS.CATEGORIES.CODE_STYLE);
} else if (metadata.type === "problem") {
tags.push(COMMON_TAGS.CATEGORIES.ERROR_PRONE);
} else if (metadata.type === "suggestion") {
tags.push(COMMON_TAGS.CATEGORIES.BEST_PRACTICES);
}
tags.push(COMMON_TAGS.CUSTOM);
// Unfortunately ESLint doesn't provide any insights into what rules are associated with specific languages
// or file extensions, so we cannot add in language tags for custom rules.
return tags;
}
function toViolation(file: string, resultMsg: Linter.LintMessage,
includeFixes: boolean, includeSuggestions: boolean,
lineStartOffsets?: number[]): Violation {
const violation: Violation = {
ruleName: resultMsg.ruleId as string,
message: resultMsg.message,
codeLocations: [{
file: file,
startLine: normalizeStartValue(resultMsg.line),
startColumn: normalizeStartValue(resultMsg.column),
endLine: normalizeEndValue(resultMsg.endLine),
endColumn: normalizeEndValue(resultMsg.endColumn),
}],
primaryLocationIndex: 0
};
if (!lineStartOffsets) {
return violation;
}
if (includeFixes && resultMsg.fix) {
violation.fixes = [convertEslintFix(file, resultMsg.fix, lineStartOffsets)];
}
if (includeSuggestions && resultMsg.suggestions?.length) {
violation.suggestions = resultMsg.suggestions.map(s =>
convertEslintSuggestion(file, s, lineStartOffsets));
}
return violation;
}
function computeLineStartOffsets(fileContent: string): number[] {
const offsets: number[] = [0];
for (let i = 0; i < fileContent.length; i++) {
if (fileContent[i] === '\n') {
offsets.push(i + 1);
}
}
return offsets;
}
function indexToLineColumn(index: number, lineStartOffsets: number[]): { line: number, column: number } {
// Binary search for the line containing this index
let low = 0;
let high = lineStartOffsets.length - 1;
while (low < high) {
const mid = Math.ceil((low + high + 1) / 2);
if (mid >= lineStartOffsets.length || lineStartOffsets[mid] > index) {
high = mid - 1;
} else {
low = mid;
}
}
return {
line: low + 1, // 1-based
column: index - lineStartOffsets[low] + 1 // 1-based
};
}
function convertEslintFix(file: string, eslintFix: Rule.Fix, lineStartOffsets: number[]): Fix {
const start = indexToLineColumn(eslintFix.range[0], lineStartOffsets);
const end = indexToLineColumn(eslintFix.range[1], lineStartOffsets);
return {
location: {
file: file,
startLine: start.line,
startColumn: start.column,
endLine: end.line,
endColumn: end.column
},
fixedCode: eslintFix.text
};
}
function convertEslintSuggestion(file: string, eslintSuggestion: Linter.LintSuggestion, lineStartOffsets: number[]): Suggestion {
const start = indexToLineColumn(eslintSuggestion.fix.range[0], lineStartOffsets);
const end = indexToLineColumn(eslintSuggestion.fix.range[1], lineStartOffsets);
return {
location: {
file: file,
startLine: start.line,
startColumn: start.column,
endLine: end.line,
endColumn: end.column
},
message: eslintSuggestion.desc
};
}
function normalizeStartValue(startValue: number): number {
// Sometimes rules contain a negative number if the line/column is unknown, so we force 1 in that case
return Math.max(startValue, 1);
}
function normalizeEndValue(endValue: number | undefined): number | undefined {
// Sometimes rules contain a negative number if the line/column is unknown, so we force undefined in that case
/* istanbul ignore next */
return endValue && endValue > 0 ? endValue : undefined;
}
function makeRelativeTo(absFolderPath: string, absFilePath: string): string {
absFolderPath = absFolderPath.endsWith(path.sep) ?
/* istanbul ignore next */ absFolderPath : absFolderPath + path.sep;
return absFilePath.startsWith(absFolderPath) ?
absFilePath.slice(absFolderPath.length) : /* istanbul ignore next */ absFilePath;
}