diff --git a/packages/plugin-js-packages/package.json b/packages/plugin-js-packages/package.json index 46aabad6b..d26bcc29d 100644 --- a/packages/plugin-js-packages/package.json +++ b/packages/plugin-js-packages/package.json @@ -39,6 +39,7 @@ "dependencies": { "@code-pushup/models": "0.97.0", "@code-pushup/utils": "0.97.0", + "ansis": "^3.3.2", "build-md": "^0.4.1", "semver": "^7.6.0", "zod": "^4.0.5" diff --git a/packages/plugin-js-packages/src/lib/constants.ts b/packages/plugin-js-packages/src/lib/constants.ts index 8f51db12d..4f555f0ed 100644 --- a/packages/plugin-js-packages/src/lib/constants.ts +++ b/packages/plugin-js-packages/src/lib/constants.ts @@ -2,6 +2,9 @@ import type { IssueSeverity } from '@code-pushup/models'; import type { DependencyGroup, PackageAuditLevel } from './config.js'; import type { DependencyGroupLong } from './runner/outdated/types.js'; +export const JS_PACKAGES_PLUGIN_SLUG = 'js-packages'; +export const JS_PACKAGES_PLUGIN_TITLE = 'JS packages'; + export const defaultAuditLevelMapping: Record< PackageAuditLevel, IssueSeverity diff --git a/packages/plugin-js-packages/src/lib/format.ts b/packages/plugin-js-packages/src/lib/format.ts new file mode 100644 index 000000000..35f161e3d --- /dev/null +++ b/packages/plugin-js-packages/src/lib/format.ts @@ -0,0 +1,4 @@ +import { pluginMetaLogFormatter } from '@code-pushup/utils'; +import { JS_PACKAGES_PLUGIN_TITLE } from './constants.js'; + +export const formatMetaLog = pluginMetaLogFormatter(JS_PACKAGES_PLUGIN_TITLE); diff --git a/packages/plugin-js-packages/src/lib/js-packages-plugin.ts b/packages/plugin-js-packages/src/lib/js-packages-plugin.ts index a66216617..666408317 100644 --- a/packages/plugin-js-packages/src/lib/js-packages-plugin.ts +++ b/packages/plugin-js-packages/src/lib/js-packages-plugin.ts @@ -1,5 +1,7 @@ +import ansis from 'ansis'; import { createRequire } from 'node:module'; import type { Audit, Group, PluginConfig } from '@code-pushup/models'; +import { logger, pluralizeToken } from '@code-pushup/utils'; import { type DependencyGroup, type JSPackagesPluginConfig, @@ -7,7 +9,13 @@ import { type PackageManagerId, dependencyGroups, } from './config.js'; -import { dependencyDocs, dependencyGroupWeights } from './constants.js'; +import { + JS_PACKAGES_PLUGIN_SLUG, + JS_PACKAGES_PLUGIN_TITLE, + dependencyDocs, + dependencyGroupWeights, +} from './constants.js'; +import { formatMetaLog } from './format.js'; import { packageManagers } from './package-managers/package-managers.js'; import { createRunnerFunction } from './runner/runner.js'; import { normalizeConfig } from './utils.js'; @@ -44,17 +52,26 @@ export async function jsPackagesPlugin( '../../package.json', ) as typeof import('../../package.json'); + const audits = createAudits(packageManager.slug, checks, depGroups); + const groups = createGroups(packageManager.slug, checks, depGroups); + + logger.info( + formatMetaLog( + `Created ${pluralizeToken('audit', audits.length)} and ${pluralizeToken('group', groups.length)} for ${ansis.bold(packageManager.name)} package manager`, + ), + ); + return { - slug: 'js-packages', - title: 'JS Packages', + slug: JS_PACKAGES_PLUGIN_SLUG, + title: JS_PACKAGES_PLUGIN_TITLE, icon: packageManager.icon, description: 'This plugin runs audit to uncover vulnerabilities and lists outdated dependencies. It supports npm, yarn classic, yarn modern, and pnpm package managers.', docsUrl: packageManager.docs.homepage, packageName: packageJson.name, version: packageJson.version, - audits: createAudits(packageManager.slug, checks, depGroups), - groups: createGroups(packageManager.slug, checks, depGroups), + audits, + groups, runner: createRunnerFunction({ ...jsPackagesPluginConfigRest, checks, diff --git a/packages/plugin-js-packages/src/lib/js-packages-plugin.unit.test.ts b/packages/plugin-js-packages/src/lib/js-packages-plugin.unit.test.ts index 66a4551ec..21afeb02c 100644 --- a/packages/plugin-js-packages/src/lib/js-packages-plugin.unit.test.ts +++ b/packages/plugin-js-packages/src/lib/js-packages-plugin.unit.test.ts @@ -23,7 +23,7 @@ describe('jsPackagesPlugin', () => { await expect(jsPackagesPlugin()).resolves.toStrictEqual( expect.objectContaining({ slug: 'js-packages', - title: 'JS Packages', + title: 'JS packages', audits: expect.arrayContaining([ expect.objectContaining({ slug: 'npm-audit-prod' }), expect.objectContaining({ slug: 'npm-audit-dev' }), @@ -49,7 +49,7 @@ describe('jsPackagesPlugin', () => { ).resolves.toStrictEqual( expect.objectContaining({ slug: 'js-packages', - title: 'JS Packages', + title: 'JS packages', audits: expect.any(Array), groups: expect.any(Array), runner: expect.any(Function), diff --git a/packages/plugin-js-packages/src/lib/package-managers/derive-package-manager.ts b/packages/plugin-js-packages/src/lib/package-managers/derive-package-manager.ts index e527603e5..2906d6b21 100644 --- a/packages/plugin-js-packages/src/lib/package-managers/derive-package-manager.ts +++ b/packages/plugin-js-packages/src/lib/package-managers/derive-package-manager.ts @@ -1,8 +1,10 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; -import { fileExists } from '@code-pushup/utils'; +import { fileExists, logger } from '@code-pushup/utils'; import type { PackageManagerId } from '../config.js'; +import { formatMetaLog } from '../format.js'; import { deriveYarnVersion } from './derive-yarn.js'; +import { packageManagers } from './package-managers.js'; export async function derivePackageManagerInPackageJson( currentDir = process.cwd(), @@ -35,17 +37,27 @@ export async function derivePackageManager( const pkgManagerFromPackageJson = await derivePackageManagerInPackageJson(currentDir); if (pkgManagerFromPackageJson) { + logDerivedPackageManager( + pkgManagerFromPackageJson, + 'packageManager field in package.json', + ); return pkgManagerFromPackageJson; } // Check for lock files if (await fileExists(path.join(currentDir, 'package-lock.json'))) { + logDerivedPackageManager('npm', 'existence of package-lock.json file'); return 'npm'; } else if (await fileExists(path.join(currentDir, 'pnpm-lock.yaml'))) { + logDerivedPackageManager('pnpm', 'existence of pnpm-lock.yaml file'); return 'pnpm'; } else if (await fileExists(path.join(currentDir, 'yarn.lock'))) { const yarnVersion = await deriveYarnVersion(); if (yarnVersion) { + logDerivedPackageManager( + yarnVersion, + 'existence of yarn.lock file and yarn -v command', + ); return yarnVersion; } } @@ -54,3 +66,15 @@ export async function derivePackageManager( 'Could not detect package manager. Please provide it in the js-packages plugin config.', ); } + +function logDerivedPackageManager( + id: PackageManagerId, + sourceDescription: string, +): void { + const pm = packageManagers[id]; + logger.info( + formatMetaLog( + `Inferred ${pm.name} package manager from ${sourceDescription}`, + ), + ); +} diff --git a/packages/plugin-js-packages/src/lib/package-managers/npm/npm.ts b/packages/plugin-js-packages/src/lib/package-managers/npm/npm.ts index c4d382397..02703e3cd 100644 --- a/packages/plugin-js-packages/src/lib/package-managers/npm/npm.ts +++ b/packages/plugin-js-packages/src/lib/package-managers/npm/npm.ts @@ -13,7 +13,7 @@ const npmDependencyOptions: Record = { export const npmPackageManager: PackageManager = { slug: 'npm', - name: 'NPM', + name: 'npm', command: 'npm', icon: 'npm', docs: { diff --git a/packages/plugin-js-packages/src/lib/package-managers/yarn-modern/yarn-modern.ts b/packages/plugin-js-packages/src/lib/package-managers/yarn-modern/yarn-modern.ts index a01ccc0b2..26410d82f 100644 --- a/packages/plugin-js-packages/src/lib/package-managers/yarn-modern/yarn-modern.ts +++ b/packages/plugin-js-packages/src/lib/package-managers/yarn-modern/yarn-modern.ts @@ -13,7 +13,7 @@ const yarnModernEnvironmentOptions: Record = { export const yarnModernPackageManager: PackageManager = { slug: 'yarn-modern', - name: 'yarn-modern', + name: 'Yarn v2+', command: 'yarn', icon: 'yarn', docs: { diff --git a/packages/plugin-js-packages/src/lib/runner/runner.ts b/packages/plugin-js-packages/src/lib/runner/runner.ts index 04b9338dd..4a530bb75 100644 --- a/packages/plugin-js-packages/src/lib/runner/runner.ts +++ b/packages/plugin-js-packages/src/lib/runner/runner.ts @@ -1,17 +1,28 @@ import path from 'node:path'; -import type { RunnerFunction } from '@code-pushup/models'; +import type { + RunnerFunction, + TableColumnObject, + TableRowObject, +} from '@code-pushup/models'; import { asyncSequential, + capitalize, executeProcess, + formatAsciiTable, + logger, objectFromEntries, + objectToEntries, + pluralizeToken, } from '@code-pushup/utils'; import { type AuditSeverity, type DependencyGroup, type FinalJSPackagesPluginConfig, + type PackageAuditLevel, type PackageJsonPath, type PackageManagerId, dependencyGroups, + packageAuditLevels, } from '../config.js'; import { dependencyGroupToLong } from '../constants.js'; import { packageManagers } from '../package-managers/package-managers.js'; @@ -54,6 +65,8 @@ async function processOutdated( depGroups: DependencyGroup[], packageJsonPath: PackageJsonPath, ) { + logger.info('Looking for outdated packages ...'); + const pm = packageManagers[id]; const { stdout } = await executeProcess({ command: pm.command, @@ -62,9 +75,13 @@ async function processOutdated( ignoreExitCode: true, // outdated returns exit code 1 when outdated dependencies are found }); + const normalizedResult = pm.outdated.unifyResult(stdout); + logger.info( + `Detected ${pluralizeToken('outdated package', normalizedResult.length)} in total`, + ); + const depTotals = await getTotalDependencies(packageJsonPath); - const normalizedResult = pm.outdated.unifyResult(stdout); return depGroups.map(depGroup => outdatedResultToAuditOutput( normalizedResult, @@ -88,6 +105,10 @@ async function processAudit( supportedAuditDepGroups.includes(group), ); + logger.info( + `Auditing packages for ${pluralizeToken('dependency group', compatibleAuditDepGroups.length)} (${compatibleAuditDepGroups.join(', ')}) ...`, + ); + const auditResults = await asyncSequential( compatibleAuditDepGroups, async (depGroup): Promise<[DependencyGroup, AuditResult]> => { @@ -104,6 +125,8 @@ async function processAudit( const resultsMap = objectFromEntries(auditResults); const uniqueResults = pm.audit.postProcessResult?.(resultsMap) ?? resultsMap; + logAuditSummary(uniqueResults); + return compatibleAuditDepGroups.map(depGroup => auditResultToAuditOutput( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -114,3 +137,66 @@ async function processAudit( ), ); } + +function logAuditSummary( + results: Partial>, +): void { + const { totalCount, countsPerLevel } = aggregateAuditResults(results); + const formattedLevels = objectToEntries(countsPerLevel) + .filter(([, count]) => count > 0) + .map(([level, count]) => `${count} ${level}`) + .join(', '); + + logger.info( + [ + `Found ${pluralizeToken('vulnerability', totalCount)} in total`, + formattedLevels && `(${formattedLevels})`, + ] + .filter(Boolean) + .join(' '), + ); + + if (!logger.isVerbose()) { + return; + } + logger.debug( + formatAsciiTable({ + columns: [ + { key: 'depGroup', label: 'Dep. group' }, + ...[...packageAuditLevels, 'total'].map( + (level): TableColumnObject => ({ + key: level, + label: capitalize(level), + align: 'right', + }), + ), + ], + rows: objectToEntries(results).map( + ([depGroup, result]): TableRowObject => ({ + depGroup, + ...result?.summary, + }), + ), + }), + ); +} + +function aggregateAuditResults( + results: Partial>, +) { + const totalCount = Object.values(results).reduce( + (acc, { vulnerabilities }) => acc + vulnerabilities.length, + 0, + ); + const countsPerLevel = Object.values(results).reduce< + Record + >( + (acc, { summary }) => + objectFromEntries( + packageAuditLevels.map(level => [level, acc[level] + summary[level]]), + ), + objectFromEntries(packageAuditLevels.map(level => [level, 0])), + ); + + return { totalCount, countsPerLevel }; +} diff --git a/packages/plugin-js-packages/src/lib/runner/utils.ts b/packages/plugin-js-packages/src/lib/runner/utils.ts index 2d615f171..cf4cfb19f 100644 --- a/packages/plugin-js-packages/src/lib/runner/utils.ts +++ b/packages/plugin-js-packages/src/lib/runner/utils.ts @@ -1,6 +1,10 @@ +import path from 'node:path'; import { + logger, objectFromEntries, + objectToEntries, objectToKeys, + pluralizeToken, readJsonFile, } from '@code-pushup/utils'; import type { AuditResult, Vulnerability } from './audit/types.js'; @@ -54,19 +58,38 @@ export function filterAuditResult( export async function getTotalDependencies( packageJsonPath: string, ): Promise { - const parsedDeps = await readJsonFile(packageJsonPath); + const formattedPath = path.relative(process.cwd(), packageJsonPath); - const mergedDeps = objectFromEntries( - dependencyGroupLong.map(group => { - const deps = parsedDeps[group]; - return [group, deps == null ? [] : objectToKeys(deps)]; - }), - ); + return logger.task( + `Counting direct dependencies in ${formattedPath}`, + async () => { + const parsedDeps = await readJsonFile(packageJsonPath); + + const mergedDeps = objectFromEntries( + dependencyGroupLong.map(group => { + const deps = parsedDeps[group]; + return [group, deps == null ? [] : objectToKeys(deps)]; + }), + ); + + const depTotals = objectFromEntries( + objectToKeys(mergedDeps).map(deps => [ + deps, + new Set(mergedDeps[deps]).size, + ]), + ); - return objectFromEntries( - objectToKeys(mergedDeps).map(deps => [ - deps, - new Set(mergedDeps[deps]).size, - ]), + const depTotal = Object.values(depTotals).reduce( + (acc, count) => acc + count, + 0, + ); + const groupsSummary = objectToEntries(depTotals) + .filter(([, count]) => count > 0) + .map(([group, count]) => `${count} ${group}`) + .join(', '); + const message = `Found ${pluralizeToken('direct dependency', depTotal)} in ${formattedPath} (${groupsSummary})`; + + return { message, result: depTotals }; + }, ); } diff --git a/packages/plugin-js-packages/src/lib/utils.unit.test.ts b/packages/plugin-js-packages/src/lib/utils.unit.test.ts index 0f2b87f81..6e47568df 100644 --- a/packages/plugin-js-packages/src/lib/utils.unit.test.ts +++ b/packages/plugin-js-packages/src/lib/utils.unit.test.ts @@ -23,7 +23,7 @@ describe('normalizeConfig', () => { }); it('should throw if no package manager is detected', async () => { - await expect(normalizeConfig()).rejects.toThrow( + await expect(normalizeConfig(undefined)).rejects.toThrow( 'Could not detect package manager. Please provide it in the js-packages plugin config.', ); }); @@ -36,7 +36,7 @@ describe('normalizeConfig', () => { packageManager: expect.objectContaining({ slug: 'npm', command: 'npm', - name: 'NPM', + name: 'npm', }), }), ); @@ -64,7 +64,7 @@ describe('normalizeConfig', () => { packageManager: expect.objectContaining({ slug: 'yarn-modern', command: 'yarn', - name: 'yarn-modern', + name: 'Yarn v2+', }), }), );