UNPKG

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
/** * @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