Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/plugin-js-packages/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions packages/plugin-js-packages/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/plugin-js-packages/src/lib/format.ts
Original file line number Diff line number Diff line change
@@ -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);
27 changes: 22 additions & 5 deletions packages/plugin-js-packages/src/lib/js-packages-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
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,
type PackageCommand,
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';
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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}`,
),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const npmDependencyOptions: Record<DependencyGroup, string[]> = {

export const npmPackageManager: PackageManager = {
slug: 'npm',
name: 'NPM',
name: 'npm',
command: 'npm',
icon: 'npm',
docs: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const yarnModernEnvironmentOptions: Record<DependencyGroup, string> = {

export const yarnModernPackageManager: PackageManager = {
slug: 'yarn-modern',
name: 'yarn-modern',
name: 'Yarn v2+',
command: 'yarn',
icon: 'yarn',
docs: {
Expand Down
90 changes: 88 additions & 2 deletions packages/plugin-js-packages/src/lib/runner/runner.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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]> => {
Expand All @@ -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
Expand All @@ -114,3 +137,66 @@ async function processAudit(
),
);
}

function logAuditSummary(
results: Partial<Record<DependencyGroup, AuditResult>>,
): 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<Record<DependencyGroup, AuditResult>>,
) {
const totalCount = Object.values(results).reduce(
(acc, { vulnerabilities }) => acc + vulnerabilities.length,
0,
);
const countsPerLevel = Object.values(results).reduce<
Record<PackageAuditLevel, number>
>(
(acc, { summary }) =>
objectFromEntries(
packageAuditLevels.map(level => [level, acc[level] + summary[level]]),
),
objectFromEntries(packageAuditLevels.map(level => [level, 0])),
);

return { totalCount, countsPerLevel };
}
47 changes: 35 additions & 12 deletions packages/plugin-js-packages/src/lib/runner/utils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -54,19 +58,38 @@ export function filterAuditResult(
export async function getTotalDependencies(
packageJsonPath: string,
): Promise<DependencyTotals> {
const parsedDeps = await readJsonFile<PackageJson>(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<PackageJson>(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 };
},
);
}
6 changes: 3 additions & 3 deletions packages/plugin-js-packages/src/lib/utils.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
});
Expand All @@ -36,7 +36,7 @@ describe('normalizeConfig', () => {
packageManager: expect.objectContaining({
slug: 'npm',
command: 'npm',
name: 'NPM',
name: 'npm',
}),
}),
);
Expand Down Expand Up @@ -64,7 +64,7 @@ describe('normalizeConfig', () => {
packageManager: expect.objectContaining({
slug: 'yarn-modern',
command: 'yarn',
name: 'yarn-modern',
name: 'Yarn v2+',
}),
}),
);
Expand Down