repolinter
Version:
Lint open source repositories
358 lines (344 loc) • 11.9 kB
JavaScript
// Copyright 2017 TODO Group. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// eslint-disable-next-line no-unused-vars
const Result = require('../lib/result')
// eslint-disable-next-line no-unused-vars
const FormatResult = require('../lib/formatresult')
const slugger = require('../lib/github_slugger')
const ERROR_SYMBOL = '❗'
const FAIL_SYMBOL = '❌'
const WARN_SYMBOL = '⚠️'
const PASS_SYMBOL = '✅'
const FIX_SYMBOL = '🔨'
const SUGGESTED_FIX = `${FIX_SYMBOL} **Suggested Fix**:`
const APPLIED_FIX = `${PASS_SYMBOL} **Applied Fix**:`
const DISCLAIMER =
'*This report was generated automatically by the Repolinter.*'
const COLLAPSE_TOP = `<details>
<summary>Click to see rules</summary>`
const COLLAPSE_BOTTOM = '</details>'
/**
* Optionally add prefix or suffix to a string if it's truthy.
*
* @private
* @param {string?} pre The optional prefix
* @param {string?} base The base string
* @param {string?} [suf] The optional suffix
* @returns {string} The concatenated string or '' if base is falsey
*/
function opWrap(pre, base, suf) {
if (base) return (pre || '') + base + (suf || '')
return ''
}
/**
* A markdown formatter for Repolinter output, designed to be used with GH issues.
* Exported as markdownFormatter.
*
* @protected
*/
class MarkdownFormatter {
/**
* Creates a header for a rule-output block.
*
* @private
* @param {string} name The name of the rule
* @param {string} symbol The status symbol to use for the rule
* @returns {string} A formatted rule header (will not include ##)
*/
static formatRuleHeading(name, symbol) {
return `${opWrap(null, symbol, ' ')}\`${name}\``
}
/**
* Creates href tag allowing a header to be linked to in an issue or PR.
* You can append the output of this function to a header to make it linkable.
*
* @private
* @param {string} name The name of the rule (unslugged)
* @returns {string} A formatted header lint (ex. <a href="#user-content-some-heading" id="some-heading">#</a>)
*/
static makeHeaderLink(name) {
const slug = slugger.slug(name)
return `<a href="#user-content-${slug}" id="user-content-${slug}">#</a>`
}
/**
* Format a FormatResult object into a line of human-readable text.
*
* @param {FormatResult} result The result to format, must be valid
* @param {string} symbol The symbol to use at the start of the log line (ex. ✅)
* @param {boolean?} [dryRun] Whether or not to say the fix is "suggested" instead of "applied".
* @returns {string} The formatted string
*/
static formatResult(result, symbol, dryRun) {
const header = MarkdownFormatter.formatRuleHeading(
result.ruleInfo.name,
symbol
)
const formatBase = [
`### ${header} ${MarkdownFormatter.makeHeaderLink(header)}`
]
if (result.status === FormatResult.ERROR) {
// the rule failed to run for some reason?
const content = `\n\nThis rule failed to run with the following error: ${result.runMessage}. `
formatBase.push(content)
if (result.ruleInfo.policyInfo) {
formatBase.push(
`${result.ruleInfo.policyInfo}.${opWrap(
' For more information please visit: ',
result.ruleInfo.policyUrl,
'.'
)}`
)
}
} else if (result.status === FormatResult.IGNORED) {
// the rule was ignored
formatBase.push(
`\n\nThis rule was ignored for the following reason: ${result.runMessage}`
)
if (result.ruleInfo.policyInfo) {
formatBase.push(
`${result.ruleInfo.policyInfo}.${opWrap(
' For more information please visit: ',
result.ruleInfo.policyUrl,
'.'
)}`
)
}
} else if (result.lintResult.targets.length <= 1 && !result.fixResult) {
// the rule passed!
// condensed version for 0-1 targets and no fix
const body =
'\n\n' +
opWrap(null, result.lintResult.message, '. ') +
opWrap(
null,
result.lintResult.targets.length &&
result.lintResult.targets[0].message,
' '
) +
opWrap(
'(`',
result.lintResult.targets.length &&
(result.lintResult.targets[0].path ||
result.lintResult.targets[0].pattern),
'`). '
) +
opWrap(null, result.ruleInfo.policyInfo, '. ') +
opWrap(
'For more information please visit ',
result.ruleInfo.policyUrl,
'.'
)
formatBase.push(body)
} else {
// normal version with bulleted list for files
// start with policy information sentence
const start =
'\n\n' +
opWrap(null, result.ruleInfo.policyInfo, '. ') +
opWrap(
'For more information please visit ',
result.ruleInfo.policyUrl,
'. '
) +
opWrap(null, result.lintResult.message, '. ')
formatBase.push(start)
// create bulleted list, filter only failed targets
const failedList = result.lintResult.targets.filter(
t => t.passed === false
)
if (failedList.length === 0) {
formatBase.push('All files passed this test.')
} else {
formatBase.push('Below is a list of files or patterns that failed:\n\n')
// format the result based on these pieces of information
const list = failedList
// match each target to it's fix result, if one exists
.map(t =>
result.fixResult && t.path
? [
t,
result.fixResult.targets.find(f => f.path === t.path) || null
]
: [t, null]
)
.map(([lintTarget, fixTarget]) => {
const base = `- \`${
lintTarget.path || lintTarget.pattern
}\`${opWrap(': ', lintTarget.message, '.')}`
// no fix format
if (!fixTarget || !fixTarget.passed) {
return base
}
// with fix format
return (
base +
`\n - ${dryRun ? SUGGESTED_FIX : APPLIED_FIX} ${
fixTarget.message || result.fixResult.message
}`
)
})
.join('\n')
formatBase.push(list)
}
}
// suggested fix for overall rule/fix combo
if (result.fixResult && result.fixResult.passed) {
// find all fixes which didn't have a lint target (haven't been displayed yet)
const unassociatedFixList = result.fixResult.targets.filter(
t => !t.path || !result.lintResult.targets.find(l => l.path === t.path)
)
// break if there aren't any
if (result.fixResult.message || unassociatedFixList.length !== 0) {
const fixSuggest = `\n\n${dryRun ? SUGGESTED_FIX : APPLIED_FIX}${opWrap(
' ',
result.fixResult.message,
'.'
)}`
formatBase.push(fixSuggest)
const fixList = unassociatedFixList.map(
f => `\n- \`${f.path || f.pattern}\`${opWrap(': ', f.message, '.')}`
)
if (fixList.length) {
formatBase.push('\n')
}
formatBase.push(...fixList)
}
}
// return the created string!
return formatBase.join('')
}
/**
* Sort a list of FormatResults based on thier status, so it's easier to
* manipulate them. Returns an object with keys of FormatResult.<status name>
* and values of an array of results.
*
* @private
* @param {FormatResult[]} results
* @returns {Object.<string, FormatResult[]>} The object representing sorted results.
*/
static sortResults(results) {
/** @ignore @type {Object.<string, FormatResult[]>} */
const out = {}
for (const key of FormatResult.getAllStatus()) {
out[key] = []
}
return results.reduce((a, c) => {
a[c.status].push(c)
return a
}, out)
}
/**
* Creates a markdown section representing a type of rule result.
*
* @private
* @param {string} name What to name the markdown section.
* @param {string} body The content of the markdown section.
* @param {boolean?} [collapse] Whether or not to have the section be collapsed by default
* @returns {string} A fully formatted markdown section
*/
static createSection(name, body, collapse = false) {
const section = `\n\n## ${name} ${MarkdownFormatter.makeHeaderLink(name)}
${collapse ? `\n${COLLAPSE_TOP}\n` : ''}
${body}
${collapse ? `\n${COLLAPSE_BOTTOM}` : ''}`
return section
}
/**
*
* @param {LintResult} output The linter output to format
* @param {string} [output.formatOptions.disclaimer] A disclaimer to put at the top of the markdown document.
* @param {boolean?} [dryRun] Whether or not to print fix "suggested" or "applied"
* @returns {string} The formatted output
*/
static formatOutput(output, dryRun) {
const formatBase = [
`# Repolinter Report\n\n${
(output.formatOptions && output.formatOptions.disclaimer) || DISCLAIMER
}`
]
// count each type of format result in an object
const sorted = MarkdownFormatter.sortResults(output.results)
// create the summary block
const summary = `\n\nThis Repolinter run generated the following results:
| ${ERROR_SYMBOL} Error | ${FAIL_SYMBOL} Fail | ${WARN_SYMBOL} Warn | ${PASS_SYMBOL} Pass | Ignored | Total |
|---|---|---|---|---|---|
| ${sorted[FormatResult.ERROR].length} | ${
sorted[FormatResult.RULE_NOT_PASSED_ERROR].length
} | ${sorted[FormatResult.RULE_NOT_PASSED_WARN].length} | ${
sorted[FormatResult.RULE_PASSED].length
} | ${sorted[FormatResult.IGNORED].length} | ${output.results.length} |`
formatBase.push(summary)
// configure each section
const sectionConfig = [
{
type: FormatResult.ERROR,
name: 'Error',
symbol: ERROR_SYMBOL,
collapse: false
},
{
type: FormatResult.RULE_NOT_PASSED_ERROR,
name: 'Fail',
symbol: FAIL_SYMBOL,
collapse: false
},
{
type: FormatResult.RULE_NOT_PASSED_WARN,
name: 'Warning',
symbol: WARN_SYMBOL,
collapse: true
},
{
type: FormatResult.RULE_PASSED,
name: 'Passed',
symbol: PASS_SYMBOL,
collapse: true
},
{
type: FormatResult.IGNORED,
name: 'Ignored',
symbol: '',
collapse: true
}
]
// filter down to sections that have items
const relevantSections = sectionConfig.filter(
cfg => sorted[cfg.type].length > 0
)
// generate the TOC
formatBase.push('\n')
const toc = relevantSections.map(cfg => {
// generate rule-items
const subItems = sorted[cfg.type].map(r => {
const heading = MarkdownFormatter.formatRuleHeading(
r.ruleInfo.name,
cfg.symbol
)
return `\n - [${heading}](#user-content-${slugger.slug(heading)})`
})
// generate top level section
return `\n- [${cfg.name}](#user-content-${slugger.slug(
cfg.name
)})${subItems.join('')}`
})
formatBase.push(...toc)
// generate content sections
const allSections = relevantSections.map(cfg =>
MarkdownFormatter.createSection(
cfg.name,
sorted[cfg.type]
.map(r => MarkdownFormatter.formatResult(r, cfg.symbol, dryRun))
.join('\n\n'),
cfg.collapse
)
)
// generate TOC
// add it to the overall format
formatBase.push(...allSections)
// add final trailing newline
formatBase.push('\n')
// generate our finished markdown document, removing all trailing whitespace
return formatBase.join('').replace(/[^\S\r\n]+$/gm, '')
}
}
module.exports = MarkdownFormatter