eslint-insight
Version:
A developer tool to display ESLint results in a clear and user-friendly format, with real-time feedback in the terminal or browser.
282 lines (247 loc) • 8.35 kB
JavaScript
/**
* @import { Issue } from 'codeclimate-types'
* @import { ESLint, Linter } from 'eslint'
*/
import { createHash } from 'node:crypto'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, relative, resolve } from 'node:path'
import { styleText } from 'node:util'
import yaml from 'yaml'
/** @type {yaml.CollectionTag} */
const reference = {
tag: '!reference',
collection: 'seq',
default: false,
resolve() {
// We only allow the syntax. We don’t actually resolve the reference.
}
}
/**
* @param {string} projectDir
* The KhulnaSoft project directory.
* @param {string | undefined} jobName
* The KhulnaSoft CI job name.
* @returns {Promise<string>}
* The output path of the code quality artifact.
*/
async function getOutputPath(projectDir, jobName) {
const configPath = join(projectDir, process.env.CI_CONFIG_PATH ?? '.khulnasoft-ci.yml')
// KhulnasoftCI allows a custom configuration path which can be a URL or a path relative to another
// project. In these cases CI_CONFIG_PATH is empty and we'll have to require the user provide
// ESLINT_CODE_QUALITY_REPORT.
let configContents
try {
configContents = await readFile(configPath, 'utf8')
} catch {
throw new Error(
'Could not resolve .khulnasoft-ci.yml to automatically detect report artifact path.' +
' Please manually provide a path via the ESLINT_CODE_QUALITY_REPORT variable.'
)
}
const doc = yaml.parseDocument(configContents, {
version: '1.1',
customTags: [reference]
})
const path = [jobName, 'artifacts', 'reports', 'codequality']
const location = doc.getIn(path)
if (typeof location !== 'string' || !location) {
throw new TypeError(
`Expected ${path.join('.')} to be one exact path, got: ${JSON.stringify(location)}`
)
}
return resolve(projectDir, location)
}
/**
* @param {string} filePath
* The path to the linted file.
* @param {Linter.LintMessage} message
* The ESLint report message.
* @param {Set<string>} hashes
* Hashes already encountered. Used to avoid duplicate hashes
* @returns {string}
* The fingerprint for the ESLint report message.
*/
function createFingerprint(filePath, message, hashes) {
const md5 = createHash('md5')
md5.update(filePath)
if (message.ruleId) {
md5.update(message.ruleId)
}
md5.update(message.message)
// Create copy of hash since md5.digest() will finalize it, not allowing us to .update() again
let md5Tmp = md5.copy()
let hash = md5Tmp.digest('hex')
while (hashes.has(hash)) {
// Hash collision. This happens if we encounter the same ESLint message in one file
// multiple times. Keep generating new hashes until we get a unique one.
md5.update(hash)
md5Tmp = md5.copy()
hash = md5Tmp.digest('hex')
}
hashes.add(hash)
return hash
}
/**
* @param {ESLint.LintResult[]} results
* The ESLint report results.
* @param {ESLint.LintResultData} data
* The ESLint report result data.
* @param {string} projectDir
* The KhulnaSoft project directory.
* @returns {Issue[]}
* The ESLint messages in the form of a KhulnaSoft code quality report.
*/
function convert(results, data, projectDir) {
/** @type {Issue[]} */
const messages = []
/** @type {Set<string>} */
const hashes = new Set()
for (const result of results) {
const relativePath = relative(projectDir, result.filePath)
for (const message of result.messages) {
/** @type {Issue} */
const issue = {
type: 'issue',
categories: ['Style'],
check_name: message.ruleId ?? '',
description: message.message,
severity: message.fatal ? 'critical' : message.severity === 2 ? 'major' : 'minor',
fingerprint: createFingerprint(relativePath, message, hashes),
location: {
path: relativePath,
lines: {
begin: message.line,
end: message.endLine ?? message.line
}
}
}
messages.push(issue)
if (!message.ruleId) {
continue
}
if (!data.rulesMeta[message.ruleId]) {
continue
}
const { docs, type } = data.rulesMeta[message.ruleId]
if (type === 'problem') {
issue.categories.unshift('Bug Risk')
}
if (!docs) {
continue
}
let body = docs.description || ''
if (docs.url) {
if (body) {
body += '\n\n'
}
body += `[${message.ruleId}](${docs.url})`
}
if (body) {
issue.content = { body }
}
}
}
return messages
}
/**
* Make a text singular or plural based on the count.
*
* @param {number} count
* The count of the data.
* @param {string} text
* The text to make singular or plural.
* @returns {string}
* The formatted text.
*/
function plural(count, text) {
return `${count} ${text}${count === 1 ? '' : 's'}`
}
/**
* @param {ESLint.LintResult[]} results
* The ESLint report results.
* @param {string} projectDir
* The KhulnaSoft project directory.
* @returns {string}
* The ESLint messages converted to a format suitable as output in KhulnaSoft CI job logs.
*/
function khulnasoftConsoleFormatter(results, projectDir) {
// Severity labels manually padded to have equal lengths and end with spaces
const labelFatal = `${styleText('magenta', 'fatal')} `
const labelError = `${styleText('red', 'error')} `
const labelWarn = `${styleText('yellow', 'warn')} `
const lines = ['']
/** @type {string | undefined} */
let khulnaSoftBaseURL
const projectUrl = process.env.CI_PROJECT_URL
const commitSha = process.env.CI_COMMIT_SHORT_SHA
if (projectUrl && commitSha) {
khulnaSoftBaseURL = `${projectUrl}/-/blob/${commitSha}/`
}
let fatal = 0
let errors = 0
let warnings = 0
let maxRuleIdLength = 0
let maxMsgLength = 0
for (const result of results) {
fatal += result.fatalErrorCount
errors += result.errorCount - result.fatalErrorCount
warnings += result.warningCount
for (const message of result.messages) {
if (message.ruleId) {
maxRuleIdLength = Math.max(maxRuleIdLength, message.ruleId.length)
}
maxMsgLength = Math.max(maxMsgLength, message.message.length)
}
}
for (const result of results) {
const { filePath, messages } = result
const repoFilePath = relative(projectDir, filePath)
for (const message of messages) {
let line = message.fatal ? labelFatal : message.severity === 1 ? labelWarn : labelError
line += String(message.ruleId || '').padEnd(maxRuleIdLength + 2)
line += message.message.padEnd(maxMsgLength + 2)
if (khulnaSoftBaseURL) {
// Create link to referenced file in KhulnaSoft
let anchor = `#L${message.line}`
if (message.endLine != null && message.endLine !== message.line) {
anchor += `-${message.endLine}`
}
line += styleText('blue', `${khulnaSoftBaseURL}${repoFilePath}${anchor}`)
} else {
line += `${filePath}:${message.line}:${message.column}`
}
lines.push(line)
}
}
const total = warnings + errors + fatal
if (total > 0) {
const details = `(${fatal} fatal, ${plural(errors, 'error')}, ${plural(warnings, 'warning')})`
lines.push('', `${styleText('red', '✖')} ${plural(total, 'problem')} ${details}`)
} else {
lines.push(`${styleText('green', '✔')} No problems found`)
}
lines.push('')
return lines.join('\n')
}
/**
* @param {ESLint.LintResult[]} results
* The ESLint report results.
* @param {ESLint.LintResultData} data
* The ESLint report result data.
* @returns {Promise<string>}
* The ESLint output to print to the console.
*/
async function eslintFormatterKhulnaSoft(results, data) {
let outputPath = process.env.ESLINT_CODE_QUALITY_REPORT
const projectDir = process.env.CI_PROJECT_DIR ?? data.cwd
const jobName = process.env.CI_JOB_NAME
if (jobName || outputPath) {
const issues = convert(results, data, projectDir)
outputPath ||= await getOutputPath(projectDir, jobName)
const dir = dirname(outputPath)
await mkdir(dir, { recursive: true })
await writeFile(outputPath, `${JSON.stringify(issues, null, 2)}\n`)
}
return khulnasoftConsoleFormatter(results, projectDir)
}
export default eslintFormatterKhulnaSoft