forked from todogroup/repolinter
-
Notifications
You must be signed in to change notification settings - Fork 0
/
index.js
522 lines (502 loc) · 17.9 KB
/
index.js
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
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
// Copyright 2017 TODO Group. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
/** @module repolinter */
const path = require('path')
const config = require('./lib/config')
const Result = require('./lib/result')
const RuleInfo = require('./lib/ruleinfo')
const FormatResult = require('./lib/formatresult')
const FileSystem = require('./lib/file_system')
const Rules = require('./rules/rules')
const Fixes = require('./fixes/fixes')
const Axioms = require('./axioms/axioms')
/**
* @typedef {Object} Formatter
* @property {function(LintResult, boolean): string} formatOutput A function to format the entire linter output.
*/
/**
* This formatter outputs the LintResult CLI style, including
* colors on supported platforms.
* ```console
* ✔ license-file-exists: found (LICENSE)
* ✔ readme-file-exists: found (README.md)
* ✔ contributing-file-exists: found (CONTRIBUTING)
* ✔ code-of-conduct-file-exists: found (CODE-OF-CONDUCT)
* ✔ changelog-file-exists: found (CHANGELOG)
* ✔ readme-references-license: File README.md contains license
* ✔ license-detectable-by-licensee: Licensee identified the license for project: Apache License 2.0
* ✔ test-directory-exists: found (tests)
* ✔ integrates-with-ci: found (.travis.yml)
* ✔ source-license-headers-exist: The first 5 lines of 'index.js' contain all of the requested patterns.
* ...
* ✔ github-issue-template-exists: found (ISSUE_TEMPLATE)
* ✔ github-pull-request-template-exists: found (PULL_REQUEST_TEMPLATE)
* ✔ package-metadata-exists: found (Gemfile)
* ✔ package-metadata-exists: found (package.json)
* ```
*
* @type {Formatter}
*/
module.exports.defaultFormatter = require('./formatters/symbol_formatter')
/**
* This formatter outputs the raw JSON string of the LintResult object.
*
* @type {Formatter}
*/
module.exports.jsonFormatter = require('./formatters/json_formatter')
/**
* This formatter outputs a markdown document designed to created into
* a GitHub issue or similar.
* ```markdown
* # Repolinter Report
*
* This Repolinter run generated the following results:
* | ❗ Error | ❌ Fail | ⚠️ Warn | ✅ Pass | Ignored | Total |
* |---|---|---|---|---|---|
* | 0 | 0 | 0 | 15 | 10 | 25 |
* ...
* ```
* You can also specify formatOptions.disclaimer to include a disclaimer
* at the top of the markdown document.
*
* @type {Formatter}
*/
module.exports.markdownFormatter = require('./formatters/markdown_formatter')
/** The same as defaultFormatter @type {Formatter} */
module.exports.resultFormatter = exports.defaultFormatter
/**
* @typedef {Object} LintResult
*
* @property {Object} params
* The parameters to the lint function call, including the found/supplied ruleset object.
* @property {string} params.targetDir The target directory repolinter was called with. May also be a git URL.
* @property {string[]} params.filterPaths The filter paths repolinter was called with.
* @property {string?} [params.rulesetPath] The path to the ruleset configuration repolinter was called with.
* @property {Object} params.ruleset The deserialized ruleset that Repolinter ran.
*
* @property {boolean} passed Whether or not all lint rules and fix rules succeeded. Will be false if an error occurred during linting.
* @property {boolean} errored Whether or not an error occurred during the linting process (ex. the configuration failed validation).
* @property {string} [errMsg] A string indication error information, will be present if errored is true.
* @property {FormatResult[]} results The output of all the linter rules.
* @property {Object.<string, Result>} targets An object representing axiom type: axiom targets.
* @property {Object} [formatOptions] Additional options to pass to the formatter, generated from the output or config.
*/
/**
* An exposed function for the repolinter engine. Use this function
* to run repolinter on a specified directory targetDir. You can
* also optionally specify which paths to allowlist (filterPaths),
* whether or not to actually commit modifications (fixes), and
* a custom ruleset object to use. This function will not throw
* an error on failure, instead indicating that an error has
* ocurred in returned value.
*
* @memberof repolinter
* @param {string} targetDir The directory of the repository to lint.
* @param {string[]} [filterPaths] A list of directories to allow linting of, or [] for all.
* @param {Object|string|null} [ruleset] A custom ruleset object with the same structure as the JSON ruleset configs, or a string path to a JSON config.
* Set to null for repolinter to automatically find it in the repository.
* @param {boolean} [encodedIsUsed] If true, repolinter expects base64 encoded string in the ruleset, we will decode the base64 encoded ruleset.
* @param {boolean} [dryRun] If true, repolinter will report suggested fixes, but will make no disk modifications.
* @returns {Promise<LintResult>} An object representing the output of the linter
*/
async function lint(
targetDir,
filterPaths = [],
ruleset = null,
encodedIsUsed = false,
dryRun = false
) {
const fileSystem = new FileSystem()
fileSystem.targetDir = targetDir
if (filterPaths.length > 0) {
fileSystem.filterPaths = filterPaths
}
let rulesetPath = null
if (!encodedIsUsed) {
if (typeof ruleset === 'string') {
if (config.isAbsoluteURL(ruleset)) {
rulesetPath = ruleset
} else {
rulesetPath = path.resolve(targetDir, ruleset)
}
} else if (!ruleset) {
rulesetPath = config.findConfig(targetDir)
}
if (rulesetPath !== null) {
try {
ruleset = await config.loadConfig(rulesetPath)
} catch (e) {
return {
params: {
targetDir,
filterPaths,
rulesetPath,
ruleset
},
passed: false,
errored: true,
/** @ignore */
errMsg: e && e.toString(),
results: [],
targets: {},
formatOptions: ruleset && ruleset.formatOptions
}
}
}
} else {
ruleset = await config.decodeConfig(ruleset)
}
// validate config
const val = await config.validateConfig(ruleset)
if (!val.passed) {
return {
params: {
targetDir,
filterPaths,
rulesetPath,
ruleset
},
passed: false,
errored: true,
/** @ignore */
errMsg: val.error,
results: [],
targets: {},
formatOptions: ruleset.formatOptions
}
}
// parse it
const configParsed = config.parseConfig(ruleset)
// determine axiom targets
/** @ignore @type {Object.<string, Result>} */
let targetObj = {}
// Identify axioms and execute them
if (ruleset.axioms) {
targetObj = await determineTargets(ruleset.axioms, fileSystem)
}
// execute ruleset
const result = await runRuleset(configParsed, targetObj, fileSystem, dryRun)
const passed = !result.find(
r =>
r.status === FormatResult.ERROR ||
(r.status !== FormatResult.IGNORED &&
r.ruleInfo.level === 'error' &&
!r.lintResult.passed)
)
// render all the results
const allFormatInfo = {
params: {
targetDir,
filterPaths,
rulesetPath,
ruleset
},
passed,
errored: false,
results: result,
targets: targetObj,
formatOptions: ruleset.formatOptions
}
return allFormatInfo
}
/**
* Checks a rule's list of axioms against a list of valid
* targets, and determines if the rule should run or not
* based on the following rules criteria:
* * The rule's list has a direct match on a target OR
* * The rule specifies a numerical axiom (ex. >) and the target
* list contains a target that matches that axiom.
*
* Supported numerical axioms are >, <, >=, <=, and = Only
*
* @memberof repolinter
* @param {string[]} validTargets The axiom target list in "target=thing" format, including the wildcard entry ("target=*").
* For numerical targets it is assumed that only one entry and the wildcard are present (e.g. ["target=2", "target=3", "target=*"] is invalid)
* @param {string[]} ruleAxioms The rule "where" specification to validate against.
* @returns {string[]} The list pf unsatisfied axioms, if any. Empty array indicates the rule should run.
*/
function shouldRuleRun(validTargets, ruleAxioms) {
// parse out numerical axioms, splitting them by name, operand, and number
const ruleRegex = /([\w-]+)((?:>|<)=?)(\d+)/i
const numericalRuleAxioms = []
const regularRuleAxioms = []
for (const ruleax of ruleAxioms) {
const match = ruleRegex.exec(ruleax)
if (match !== null && match[1] && match[2] && !isNaN(parseInt(match[3]))) {
// parse the numerical version
numericalRuleAxioms.push({
axiom: ruleax,
name: match[1],
operand: match[2],
number: parseInt(match[3])
})
} else {
// parse the non-numerical version
regularRuleAxioms.push(ruleax)
}
}
// test that every non-number axiom matches a target
// start a list of condidions that don't pass
const table = new Set(validTargets)
const failedRuleAxioms = regularRuleAxioms.filter(r => !table.has(r))
// check the numbered axioms
// convert the targets into { targetName: number } for all numerical ones
const numericalTargets = validTargets
.map(r => r.split('='))
.map(([name, maybeNumber]) => [name, parseInt(maybeNumber)])
.filter(([name, maybeNumber]) => !isNaN(maybeNumber))
/** @ts-ignore */
const numericalTargetsMap = new Map(numericalTargets)
// test each numerical Rule against it's numerical axiom, return the axioms that failed
return numericalRuleAxioms
.filter(({ axiom, name, operand, number }) => {
// get the number to test against
const target = numericalTargetsMap.get(name)
if (target === undefined) return true
// test the number based on the operand
return !(
(operand === '<' && target < number) ||
(operand === '<=' && target <= number) ||
(operand === '>' && target > number) ||
(operand === '>=' && target >= number)
)
})
.map(({ axiom }) => axiom)
.concat(failedRuleAxioms)
}
/**
* Run all operations in a ruleset, including linting and fixing. Returns
* a list of objects with the output of the linter rules
*
* @memberof repolinter
* @param {RuleInfo[]} ruleset A ruleset (list of rules with information about each). This parameter can be generated from a config using parseConfig.
* @param {Object.<string, Result>|boolean} targets The axiom targets to enable for this run of the ruleset. Structure is from the output of determineTargets. Use true for all targets.
* @param {FileSystem} fileSystem A filesystem object configured with filter paths and a target directory.
* @param {boolean} dryRun If true, repolinter will report suggested fixes, but will make no disk modifications.
* @returns {Promise<FormatResult[]>} Objects indicating the result of the linter rules
*/
async function runRuleset(ruleset, targets, fileSystem, dryRun) {
// generate a flat array of axiom string identifiers
/** @ignore @type {string[]} */
let targetArray = []
const sequentialRuleProcessingArrayList = ruleset.filter(
r => r.sequentialOnly
)
if (typeof targets !== 'boolean') {
targetArray = Object.entries(targets)
// restricted to only passed axioms
.filter(([axiomId, res]) => res.passed)
// pair the axiom ID with the axiom target array
.map(([axiomId, res]) => [axiomId, res.targets.map(t => t.path)])
// join the target arrays together into one array of all the targets
.map(([axiomId, paths]) =>
[`${axiomId}=*`].concat(paths.map(p => `${axiomId}=${p}`))
)
.reduce((a, c) => a.concat(c), [])
}
// run the ruleset
ruleset = ruleset.filter(r => !r.sequentialOnly)
const results = ruleset.map(async r => {
// check axioms and enable appropriately
if (r.level === 'off') {
return Promise.resolve(
FormatResult.CreateIgnored(r, 'ignored because level is "off"')
)
}
// filter to only targets with no matches
if (typeof targets !== 'boolean' && r.where && r.where.length) {
const ignoreReasons = shouldRuleRun(targetArray, r.where)
if (ignoreReasons.length > 0) {
return Promise.resolve(
FormatResult.CreateIgnored(
r,
`ignored due to unsatisfied condition(s): "${ignoreReasons.join(
'", "'
)}"`
)
)
}
}
// check if the rule file exists
if (!Object.prototype.hasOwnProperty.call(Rules, r.ruleType)) {
return Promise.resolve(
FormatResult.CreateError(r, `${r.ruleType} is not a valid rule`)
)
}
let result
try {
// load the rule
const ruleFunc = Rules[r.ruleType]
// run the rule!
result = await ruleFunc(fileSystem, r.ruleConfig)
} catch (e) {
return Promise.resolve(
FormatResult.CreateError(
r,
`${r.ruleType} threw an error: ${e.message}`
)
)
}
// generate fix targets
const fixTargets = !result.passed
? result.targets.filter(t => !t.passed && t.path).map(t => t.path)
: []
// if there's no fix or the rule passed, we're done
if (!r.fixType || result.passed) {
return Promise.resolve(FormatResult.CreateLintOnly(r, result))
}
// else run the fix
// check if the rule file exists
if (!Object.prototype.hasOwnProperty.call(Fixes, r.fixType)) {
return Promise.resolve(
FormatResult.CreateError(r, `${r.fixType} is not a valid fix`)
)
}
let fixresult
try {
const fixFunc = Fixes[r.fixType]
fixresult = await fixFunc(fileSystem, r.fixConfig, fixTargets, dryRun)
} catch (e) {
return Promise.resolve(
FormatResult.CreateError(r, `${r.fixType} threw an error: ${e.message}`)
)
}
// all done! return the final format object
return Promise.resolve(FormatResult.CreateLintAndFix(r, result, fixresult))
})
await Promise.all(results)
for (let i = 0; i < sequentialRuleProcessingArrayList.length; i++) {
const r = sequentialRuleProcessingArrayList[i]
// check axioms and enable appropriately
if (r.level === 'off') {
results.push(
Promise.resolve(
FormatResult.CreateIgnored(r, 'ignored because level is "off"')
)
)
continue
}
// filter to only targets with no matches
if (typeof targets !== 'boolean' && r.where && r.where.length) {
const ignoreReasons = shouldRuleRun(targetArray, r.where)
if (ignoreReasons.length > 0) {
results.push(
Promise.resolve(
FormatResult.CreateIgnored(
r,
`ignored due to unsatisfied condition(s): "${ignoreReasons.join(
'", "'
)}"`
)
)
)
continue
}
}
// check if the rule file exists
if (!Object.prototype.hasOwnProperty.call(Rules, r.ruleType)) {
results.push(
Promise.resolve(
FormatResult.CreateError(r, `${r.ruleType} is not a valid rule`)
)
)
continue
}
let result
try {
// load the rule
const ruleFunc = Rules[r.ruleType]
// run the rule!
result = await ruleFunc(fileSystem, r.ruleConfig)
} catch (e) {
results.push(
Promise.resolve(
FormatResult.CreateError(
r,
`${r.ruleType} threw an error: ${e.message}`
)
)
)
continue
}
// generate fix targets
const fixTargets = !result.passed
? result.targets.filter(t => !t.passed && t.path).map(t => t.path)
: []
// if there's no fix or the rule passed, we're done
if (!r.fixType || result.passed) {
results.push(Promise.resolve(FormatResult.CreateLintOnly(r, result)))
continue
}
// else run the fix
// check if the rule file exists
if (!Object.prototype.hasOwnProperty.call(Fixes, r.fixType)) {
results.push(
Promise.resolve(
FormatResult.CreateError(r, `${r.fixType} is not a valid fix`)
)
)
continue
}
let fixresult
try {
const fixFunc = Fixes[r.fixType]
fixresult = await fixFunc(fileSystem, r.fixConfig, fixTargets, dryRun)
} catch (e) {
results.push(
Promise.resolve(
FormatResult.CreateError(
r,
`${r.fixType} threw an error: ${e.message}`
)
)
)
continue
}
// all done! return the final format object
results.push(
Promise.resolve(FormatResult.CreateLintAndFix(r, result, fixresult))
)
}
return Promise.all(results)
}
/**
* Given an axiom configuration, determine the appropriate targets to run against
* (e.g. "target=javascript").
*
* @memberof repolinter
* @param {Object} axiomconfig A configuration conforming to the "axioms" section in schema.json
* @param {FileSystem} fs The filesystem to run axioms against
* @returns {Promise<Object.<string, Result>>} An object representing axiom name: axiom results. The array will be null if the axiom could not run.
*/
async function determineTargets(axiomconfig, fs) {
// load axioms
const ruleresults = await Promise.all(
Object.entries(axiomconfig).map(async ([axiomId, axiomName]) => {
// Execute axiom if it exists
if (!Object.prototype.hasOwnProperty.call(Axioms, axiomId)) {
return [
axiomName,
new Result(`invalid axiom name ${axiomId}`, [], false)
]
}
const axiomFunction = Axioms[axiomId]
return [axiomName, await axiomFunction(fs)]
})
)
// flatten result
return ruleresults.reduce((a, [k, v]) => {
a[k] = v
return a
}, {})
}
module.exports.runRuleset = runRuleset
module.exports.determineTargets = determineTargets
module.exports.validateConfig = config.validateConfig
module.exports.parseConfig = config.parseConfig
module.exports.shouldRuleRun = shouldRuleRun
module.exports.lint = lint
module.exports.Result = Result
module.exports.RuleInfo = RuleInfo
module.exports.FileSystem = FileSystem
module.exports.FormatResult = FormatResult