diff --git a/packages/plugin-eslint/package.json b/packages/plugin-eslint/package.json index 416644f51..e3ae91241 100644 --- a/packages/plugin-eslint/package.json +++ b/packages/plugin-eslint/package.json @@ -38,6 +38,7 @@ }, "type": "module", "dependencies": { + "ansis": "^3.3.2", "glob": "^11.0.0", "@code-pushup/utils": "0.94.0", "@code-pushup/models": "0.94.0", diff --git a/packages/plugin-eslint/src/lib/constants.ts b/packages/plugin-eslint/src/lib/constants.ts index 8f4ec0403..90040e5e1 100644 --- a/packages/plugin-eslint/src/lib/constants.ts +++ b/packages/plugin-eslint/src/lib/constants.ts @@ -1 +1,2 @@ export const ESLINT_PLUGIN_SLUG = 'eslint'; +export const ESLINT_PLUGIN_TITLE = 'ESLint'; diff --git a/packages/plugin-eslint/src/lib/eslint-plugin.ts b/packages/plugin-eslint/src/lib/eslint-plugin.ts index 079e27764..14f480e75 100644 --- a/packages/plugin-eslint/src/lib/eslint-plugin.ts +++ b/packages/plugin-eslint/src/lib/eslint-plugin.ts @@ -6,7 +6,7 @@ import { eslintPluginConfigSchema, eslintPluginOptionsSchema, } from './config.js'; -import { ESLINT_PLUGIN_SLUG } from './constants.js'; +import { ESLINT_PLUGIN_SLUG, ESLINT_PLUGIN_TITLE } from './constants.js'; import { listAuditsAndGroups } from './meta/index.js'; import { createRunnerFunction } from './runner/index.js'; @@ -51,7 +51,7 @@ export async function eslintPlugin( return { slug: ESLINT_PLUGIN_SLUG, - title: 'ESLint', + title: ESLINT_PLUGIN_TITLE, icon: 'eslint', description: 'Official Code PushUp ESLint plugin', docsUrl: 'https://www.npmjs.com/package/@code-pushup/eslint-plugin', @@ -61,7 +61,7 @@ export async function eslintPlugin( audits, groups, - runner: await createRunnerFunction({ + runner: createRunnerFunction({ audits, targets, ...(artifacts ? { artifacts } : {}), diff --git a/packages/plugin-eslint/src/lib/meta/format.ts b/packages/plugin-eslint/src/lib/meta/format.ts new file mode 100644 index 000000000..f0acf7166 --- /dev/null +++ b/packages/plugin-eslint/src/lib/meta/format.ts @@ -0,0 +1,4 @@ +import { pluginMetaLogFormatter } from '@code-pushup/utils'; +import { ESLINT_PLUGIN_TITLE } from '../constants.js'; + +export const formatMetaLog = pluginMetaLogFormatter(ESLINT_PLUGIN_TITLE); diff --git a/packages/plugin-eslint/src/lib/meta/index.ts b/packages/plugin-eslint/src/lib/meta/index.ts index 9303e4ba2..b40b6a8ad 100644 --- a/packages/plugin-eslint/src/lib/meta/index.ts +++ b/packages/plugin-eslint/src/lib/meta/index.ts @@ -1,5 +1,7 @@ import type { Audit, Group } from '@code-pushup/models'; +import { formatAsciiTable, logger, pluralizeToken } from '@code-pushup/utils'; import type { CustomGroup, ESLintTarget } from '../config.js'; +import { formatMetaLog } from './format.js'; import { groupsFromCustomConfig, groupsFromRuleCategories, @@ -10,24 +12,50 @@ import { ruleToAudit } from './transform.js'; export { ruleIdToSlug } from './hash.js'; export { detectConfigVersion, type ConfigFormat } from './versions/index.js'; +export { formatMetaLog }; export async function listAuditsAndGroups( targets: ESLintTarget[], customGroups?: CustomGroup[] | undefined, ): Promise<{ audits: Audit[]; groups: Group[] }> { const rules = await listRules(targets); + const audits = rules.map(ruleToAudit); + + logger.info( + formatMetaLog( + `Found ${pluralizeToken('rule', rules.length)} in total for ${pluralizeToken('target', targets.length)}, mapped to audits`, + ), + ); + const resolvedTypeGroups = groupsFromRuleTypes(rules); + const resolvedCategoryGroups = groupsFromRuleCategories(rules); const resolvedCustomGroups = customGroups ? groupsFromCustomConfig(rules, customGroups) : []; - - const audits = rules.map(ruleToAudit); - const groups = [ - ...groupsFromRuleTypes(rules), - ...groupsFromRuleCategories(rules), + ...resolvedTypeGroups, + ...resolvedCategoryGroups, ...resolvedCustomGroups, ]; + logger.info( + formatMetaLog( + `Created ${pluralizeToken('group', groups.length)} (${resolvedTypeGroups.length} from meta.type, ${resolvedCategoryGroups.length} from meta.docs.category, ${resolvedCustomGroups.length} from custom groups)`, + ), + ); + logger.debug( + formatMetaLog( + formatAsciiTable( + { + rows: groups.map(group => [ + `• ${group.title}`, + pluralizeToken('audit', group.refs.length), + ]), + }, + { borderless: true }, + ), + ), + ); + return { audits, groups }; } diff --git a/packages/plugin-eslint/src/lib/meta/rules.ts b/packages/plugin-eslint/src/lib/meta/rules.ts index a64429425..9b8c1be83 100644 --- a/packages/plugin-eslint/src/lib/meta/rules.ts +++ b/packages/plugin-eslint/src/lib/meta/rules.ts @@ -1,4 +1,7 @@ +import ansis from 'ansis'; +import { logger, pluralize, pluralizeToken, toArray } from '@code-pushup/utils'; import type { ESLintTarget } from '../config.js'; +import { formatMetaLog } from './format.js'; import { jsonHash } from './hash.js'; import type { RuleData } from './parse.js'; import { detectConfigVersion, selectRulesLoader } from './versions/index.js'; @@ -8,10 +11,16 @@ type RulesMap = Record>; export async function listRules(targets: ESLintTarget[]): Promise { const version = await detectConfigVersion(); const loadRulesMap = selectRulesLoader(version); + logger.debug(formatMetaLog(`Detected ${version} config format`)); const rulesMap = await targets.reduce(async (acc, target) => { const map = await acc; const rules = await loadRulesMap(target); + logger.debug( + formatMetaLog( + `Found ${pluralizeToken('rule', rules.length)} for ${formatTarget(target)}`, + ), + ); return rules.reduce(mergeRuleIntoMap, map); }, Promise.resolve({})); @@ -28,6 +37,16 @@ function mergeRuleIntoMap(map: RulesMap, rule: RuleData): RulesMap { }; } +function formatTarget(target: ESLintTarget): string { + const patterns = toArray(target.patterns); + return [ + `${pluralize('pattern', patterns.length)} ${ansis.bold(patterns.join(' '))}`, + target.eslintrc && `using config ${ansis.bold(target.eslintrc)}`, + ] + .filter(Boolean) + .join(' '); +} + export function expandWildcardRules( wildcard: string, rules: string[], diff --git a/packages/plugin-eslint/src/lib/nx/find-all-projects.ts b/packages/plugin-eslint/src/lib/nx/find-all-projects.ts index e56db7f9c..a0ad231d5 100644 --- a/packages/plugin-eslint/src/lib/nx/find-all-projects.ts +++ b/packages/plugin-eslint/src/lib/nx/find-all-projects.ts @@ -1,5 +1,6 @@ -import { logger, stringifyError } from '@code-pushup/utils'; +import { logger, pluralizeToken, stringifyError } from '@code-pushup/utils'; import type { ESLintTarget } from '../config.js'; +import { formatMetaLog } from '../meta/format.js'; import { filterProjectGraph } from './filter-project-graph.js'; import { nxProjectsToConfig } from './projects-to-config.js'; @@ -14,7 +15,7 @@ async function resolveCachedProjectGraph() { try { return readCachedProjectGraph(); } catch (error) { - logger.info( + logger.warn( `Could not read cached project graph, falling back to async creation.\n${stringifyError(error)}`, ); return await createProjectGraphAsync({ exitOnError: false }); @@ -55,7 +56,21 @@ export async function eslintConfigFromAllNxProjects( projectGraph, options.exclude, ); - return nxProjectsToConfig(filteredProjectGraph); + const targets = await nxProjectsToConfig(filteredProjectGraph); + + logger.info( + formatMetaLog( + [ + `Inferred ${pluralizeToken('lint target', targets.length)} for all Nx projects`, + options.exclude?.length && + `(excluding ${pluralizeToken('project', options.exclude.length)})`, + ] + .filter(Boolean) + .join(' '), + ), + ); + + return targets; } /** diff --git a/packages/plugin-eslint/src/lib/nx/find-project-with-deps.ts b/packages/plugin-eslint/src/lib/nx/find-project-with-deps.ts index 55279f828..fa90f950c 100644 --- a/packages/plugin-eslint/src/lib/nx/find-project-with-deps.ts +++ b/packages/plugin-eslint/src/lib/nx/find-project-with-deps.ts @@ -1,4 +1,7 @@ +import { logger } from '@nx/devkit'; +import { pluralizeToken } from '@code-pushup/utils'; import type { ESLintTarget } from '../config.js'; +import { formatMetaLog } from '../meta/format.js'; import { nxProjectsToConfig } from './projects-to-config.js'; import { findAllDependencies } from './traverse-graph.js'; @@ -35,10 +38,18 @@ export async function eslintConfigFromNxProjectAndDeps( const dependencies = findAllDependencies(projectName, projectGraph); - return nxProjectsToConfig( + const targets = await nxProjectsToConfig( projectGraph, project => !!project.name && (project.name === projectName || dependencies.has(project.name)), ); + + logger.info( + formatMetaLog( + `Inferred ${pluralizeToken('lint target', targets.length)} for Nx project "${projectName}" and its dependencies`, + ), + ); + + return targets; } diff --git a/packages/plugin-eslint/src/lib/nx/find-project-without-deps.ts b/packages/plugin-eslint/src/lib/nx/find-project-without-deps.ts index abf78c56c..70bd1573b 100644 --- a/packages/plugin-eslint/src/lib/nx/find-project-without-deps.ts +++ b/packages/plugin-eslint/src/lib/nx/find-project-without-deps.ts @@ -1,4 +1,6 @@ +import { logger } from '@code-pushup/utils'; import type { ESLintTarget } from '../config.js'; +import { formatMetaLog } from '../meta/format.js'; import { nxProjectsToConfig } from './projects-to-config.js'; /** @@ -41,5 +43,9 @@ export async function eslintConfigFromNxProject( throw new Error(`Couldn't find Nx project named "${projectName}"`); } + logger.info( + formatMetaLog(`Inferred lint target for Nx project "${projectName}"`), + ); + return project; } diff --git a/packages/plugin-eslint/src/lib/runner/index.ts b/packages/plugin-eslint/src/lib/runner/index.ts index d6c34472d..95ce3e5b6 100644 --- a/packages/plugin-eslint/src/lib/runner/index.ts +++ b/packages/plugin-eslint/src/lib/runner/index.ts @@ -5,9 +5,15 @@ import type { PluginArtifactOptions, RunnerFunction, } from '@code-pushup/models'; -import { asyncSequential, logger } from '@code-pushup/utils'; +import { + asyncSequential, + logger, + pluralizeToken, + roundDecimals, +} from '@code-pushup/utils'; import type { ESLintPluginRunnerConfig, ESLintTarget } from '../config.js'; import { lint } from './lint.js'; +import { aggregateLintResultsStats } from './stats.js'; import { lintResultsToAudits, mergeLinterOutputs } from './transform.js'; import { loadArtifacts } from './utils.js'; @@ -23,14 +29,32 @@ export function createRunnerFunction(options: { }; return async (): Promise => { - logger.info(`ESLint plugin executing ${targets.length} lint targets`); + logger.info( + `ESLint plugin executing ${pluralizeToken('lint target', targets.length)}`, + ); const linterOutputs = artifacts ? await loadArtifacts(artifacts) : await asyncSequential(targets, lint); + const lintResults = mergeLinterOutputs(linterOutputs); const failedAudits = lintResultsToAudits(lintResults); + const stats = aggregateLintResultsStats(lintResults.results); + logger.info( + stats.problemsCount === 0 + ? 'ESLint did not find any problems' + : `ESLint found ${pluralizeToken('problem', stats.problemsCount)} from ${pluralizeToken('rule', stats.failedRulesCount)} across ${pluralizeToken('file', stats.failedFilesCount)}`, + ); + + const totalCount = config.slugs.length; + const failedCount = failedAudits.length; + const passedCount = totalCount - failedCount; + const percentage = roundDecimals((passedCount / totalCount) * 100, 2); + logger.info( + `${pluralizeToken('audit', passedCount)} passed, ${pluralizeToken('audit', failedCount)} failed (${percentage}% success)`, + ); + return config.slugs.map( (slug): AuditOutput => failedAudits.find(audit => audit.slug === slug) ?? { diff --git a/packages/plugin-eslint/src/lib/runner/stats.ts b/packages/plugin-eslint/src/lib/runner/stats.ts new file mode 100644 index 000000000..b7154f1c3 --- /dev/null +++ b/packages/plugin-eslint/src/lib/runner/stats.ts @@ -0,0 +1,23 @@ +import type { ESLint } from 'eslint'; + +export type LintResultsStats = { + problemsCount: number; + failedFilesCount: number; + failedRulesCount: number; +}; + +export function aggregateLintResultsStats( + results: ESLint.LintResult[], +): LintResultsStats { + const problemsCount = results.reduce( + (acc, result) => acc + result.messages.length, + 0, + ); + const failedFilesCount = results.length; + const failedRulesCount = new Set( + results.flatMap(({ messages }) => + messages.map(({ ruleId }) => ruleId).filter(Boolean), + ), + ).size; + return { problemsCount, failedFilesCount, failedRulesCount }; +} diff --git a/packages/plugin-eslint/src/lib/runner/stats.unit.test.ts b/packages/plugin-eslint/src/lib/runner/stats.unit.test.ts new file mode 100644 index 000000000..fbc250438 --- /dev/null +++ b/packages/plugin-eslint/src/lib/runner/stats.unit.test.ts @@ -0,0 +1,72 @@ +import type { ESLint } from 'eslint'; +import { type LintResultsStats, aggregateLintResultsStats } from './stats.js'; + +describe('aggregateLintResultsStats', () => { + it('should sum all errors and warning across all files', () => { + expect( + aggregateLintResultsStats([ + { + filePath: 'src/main.js', + messages: [{ severity: 2 }], + }, + { + filePath: 'src/lib/index.js', + messages: [{ severity: 2 }, { severity: 1 }], + }, + { + filePath: 'src/lib/utils.js', + messages: [ + { severity: 1 }, + { severity: 1 }, + { severity: 2 }, + { severity: 1 }, + ], + }, + ] as ESLint.LintResult[]), + ).toEqual( + expect.objectContaining>({ + problemsCount: 7, + }), + ); + }); + + it('should count files with problems', () => { + expect( + aggregateLintResultsStats([ + { filePath: 'src/main.js', messages: [{}] }, + { filePath: 'src/lib/index.js', messages: [{}, {}] }, + { filePath: 'src/lib/utils.js', messages: [{}, {}, {}] }, + ] as ESLint.LintResult[]), + ).toEqual( + expect.objectContaining>({ + failedFilesCount: 3, + }), + ); + }); + + it('should count unique rules with reported problems', () => { + expect( + aggregateLintResultsStats([ + { filePath: 'src/lib/main.js', messages: [{}] }, // empty ruleId ignored + { + filePath: 'src/lib/index.js', + messages: [{ ruleId: 'max-lines' }, { ruleId: 'no-unused-vars' }], + }, + { + filePath: 'src/lib/utils.js', + messages: [ + { ruleId: 'no-unused-vars' }, + { ruleId: 'eqeqeq' }, + { ruleId: 'no-unused-vars' }, + { ruleId: 'yoda' }, + { ruleId: 'eqeqeq' }, + ], + }, + ] as ESLint.LintResult[]), + ).toEqual( + expect.objectContaining>({ + failedRulesCount: 4, // no-unused-vars (3), eqeqeq (2), max-lines (1), yoda (1) + }), + ); + }); +}); diff --git a/packages/plugin-eslint/src/lib/runner/utils.ts b/packages/plugin-eslint/src/lib/runner/utils.ts index 2e23354af..7f8313f65 100644 --- a/packages/plugin-eslint/src/lib/runner/utils.ts +++ b/packages/plugin-eslint/src/lib/runner/utils.ts @@ -1,7 +1,14 @@ import type { ESLint } from 'eslint'; import { glob } from 'glob'; import type { PluginArtifactOptions } from '@code-pushup/models'; -import { executeProcess, readJsonFile } from '@code-pushup/utils'; +import { + executeProcess, + logger, + pluralize, + pluralizeToken, + readJsonFile, + toArray, +} from '@code-pushup/utils'; import type { LinterOutput } from './types.js'; export async function loadArtifacts( @@ -20,13 +27,11 @@ export async function loadArtifacts( }); } - const initialArtifactPaths = Array.isArray(artifacts.artifactsPaths) - ? artifacts.artifactsPaths - : [artifacts.artifactsPaths]; + const artifactPatterns = toArray(artifacts.artifactsPaths); - const artifactPaths = await glob(initialArtifactPaths); + const artifactPaths = await glob(artifactPatterns); - return await Promise.all( + const outputs = await Promise.all( artifactPaths.map(async artifactPath => { // ESLint CLI outputs raw ESLint.LintResult[], but we need LinterOutput format const results = await readJsonFile(artifactPath); @@ -36,4 +41,10 @@ export async function loadArtifacts( }; }), ); + + logger.info( + `Loaded lint outputs from ${pluralizeToken('artifact', artifactPaths.length)} matching ${pluralize('pattern', artifactPatterns.length)}: ${artifactPatterns.join(' ')}`, + ); + + return outputs; } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c878b9e1f..05be1d275 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -50,6 +50,7 @@ export { formatBytes, formatDuration, indentLines, + pluginMetaLogFormatter, pluralize, pluralizeToken, roundDecimals, diff --git a/packages/utils/src/lib/formatting.ts b/packages/utils/src/lib/formatting.ts index 8463dfc46..b4ab00bb4 100644 --- a/packages/utils/src/lib/formatting.ts +++ b/packages/utils/src/lib/formatting.ts @@ -1,3 +1,5 @@ +import ansis from 'ansis'; +import stringWidth from 'string-width'; import { MAX_DESCRIPTION_LENGTH, MAX_ISSUE_MESSAGE_LENGTH, @@ -146,7 +148,7 @@ export function truncateMultilineText( export function transformLines( text: string, - fn: (line: string) => string, + fn: (line: string, index: number) => string, ): string { return text.split(/\r?\n/).map(fn).join('\n'); } @@ -164,3 +166,15 @@ export function serializeCommandWithArgs({ }): string { return [command, ...(args ?? [])].join(' '); } + +export function pluginMetaLogFormatter( + title: string, +): (message: string) => string { + const prefix = ansis.blue(`[${title}]`); + const padding = ' '.repeat(stringWidth(prefix)); + return message => + transformLines( + message, + (line, idx) => `${idx === 0 ? prefix : padding} ${line}`, + ); +} diff --git a/packages/utils/src/lib/formatting.unit.test.ts b/packages/utils/src/lib/formatting.unit.test.ts index b4fdc4e24..1552949d1 100644 --- a/packages/utils/src/lib/formatting.unit.test.ts +++ b/packages/utils/src/lib/formatting.unit.test.ts @@ -5,6 +5,7 @@ import { formatDate, formatDuration, indentLines, + pluginMetaLogFormatter, pluralize, pluralizeToken, roundDecimals, @@ -269,3 +270,28 @@ describe('serializeCommandWithArgs', () => { expect(serializeCommandWithArgs({ command: 'ls' })).toBe('ls'); }); }); + +describe('pluginMetaLogFormatter', () => { + it('should prefix plugin title', () => { + expect(pluginMetaLogFormatter('ESLint')('Found 42 rules in total')).toBe( + `${ansis.blue('[ESLint]')} Found 42 rules in total`, + ); + }); + + it('should align multiline message with prefix', () => { + expect( + ansis.strip( + pluginMetaLogFormatter('Coverage')( + 'Created 3 groups:\n- Line coverage\n- Branch coverage\n- Function coverage', + ), + ), + ).toBe( + ` +[Coverage] Created 3 groups: + - Line coverage + - Branch coverage + - Function coverage +`.trim(), + ); + }); +});