Skip to content

Commit

Permalink
feat(plugin-js-packages): support multiple package.json and auto search
Browse files Browse the repository at this point in the history
  • Loading branch information
Tlacenka committed Jun 13, 2024
1 parent 4f1a8b0 commit df87ff9
Show file tree
Hide file tree
Showing 8 changed files with 146 additions and 32 deletions.
2 changes: 1 addition & 1 deletion packages/plugin-js-packages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ The plugin accepts the following parameters:
- `packageManager`: The package manager you are using. Supported values: `npm`, `yarn-classic` (v1), `yarn-modern` (v2+), `pnpm`.
- (optional) `checks`: Array of checks to be run. Supported commands: `audit`, `outdated`. Both are configured by default.
- (optional) `dependencyGroups`: Array of dependency groups to be checked. `prod` and `dev` are configured by default. `optional` are opt-in.
- (optional) `packageJsonPath`: File path to `package.json`. Defaults to current folder. Multiple `package.json` files are currently not supported.
- (optional) `packageJsonPaths`: File path(s) to `package.json`. Root `package.json` is used by default. Multiple `package.json` paths may be passed. If `{ autoSearch: true }` is provided, all `package.json` files in the repository are searched.
- (optional) `auditLevelMapping`: If you wish to set a custom level of issue severity based on audit vulnerability level, you may do so here. Any omitted values will be filled in by defaults. Audit levels are: `critical`, `high`, `moderate`, `low` and `info`. Issue severities are: `error`, `warn` and `info`. By default the mapping is as follows: `critical` and `high``error`; `moderate` and `low``warning`; `info``info`.

### Audits and group
Expand Down
17 changes: 13 additions & 4 deletions packages/plugin-js-packages/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ const packageManagerIdSchema = z.enum([
]);
export type PackageManagerId = z.infer<typeof packageManagerIdSchema>;

const packageJsonPathSchema = z
.union([
z.array(z.string()).min(1),
z.object({ autoSearch: z.literal(true) }),
])
.describe(
'File paths to package.json. Looks only at root package.json by default',
)
.default(['package.json']);

export type PackageJsonPaths = z.infer<typeof packageJsonPathSchema>;

export const packageAuditLevels = [
'critical',
'high',
Expand Down Expand Up @@ -63,10 +75,7 @@ export const jsPackagesPluginConfigSchema = z.object({
})
.default(defaultAuditLevelMapping)
.transform(fillAuditLevelMapping),
packageJsonPath: z
.string()
.describe('File path to package.json. Defaults to current folder.')
.default('package.json'),
packageJsonPaths: packageJsonPathSchema,
});

export type JSPackagesPluginConfig = z.input<
Expand Down
13 changes: 11 additions & 2 deletions packages/plugin-js-packages/src/lib/config.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe('jsPackagesPluginConfigSchema', () => {
checks: ['audit'],
packageManager: 'yarn-classic',
dependencyGroups: ['prod'],
packageJsonPath: './ui-app/package.json',
packageJsonPaths: ['./ui-app/package.json', './ui-e2e/package.json'],
} satisfies JSPackagesPluginConfig),
).not.toThrow();
});
Expand All @@ -36,7 +36,7 @@ describe('jsPackagesPluginConfigSchema', () => {
checks: ['audit', 'outdated'],
packageManager: 'npm',
dependencyGroups: ['prod', 'dev'],
packageJsonPath: 'package.json',
packageJsonPaths: ['package.json'],
auditLevelMapping: {
critical: 'error',
high: 'error',
Expand All @@ -47,6 +47,15 @@ describe('jsPackagesPluginConfigSchema', () => {
});
});

it('should accept auto search for package.json files', () => {
expect(() =>
jsPackagesPluginConfigSchema.parse({
packageManager: 'yarn-classic',
packageJsonPaths: { autoSearch: true },
} satisfies JSPackagesPluginConfig),
).not.toThrow();
});

it('should throw for no passed commands', () => {
expect(() =>
jsPackagesPluginConfigSchema.parse({
Expand Down
18 changes: 12 additions & 6 deletions packages/plugin-js-packages/src/lib/runner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@ import {
AuditSeverity,
DependencyGroup,
FinalJSPackagesPluginConfig,
PackageJsonPaths,
PackageManagerId,
dependencyGroups,
} from '../config';
import { dependencyGroupToLong } from '../constants';
import { packageManagers } from '../package-managers';
import { auditResultToAuditOutput } from './audit/transform';
import { AuditResult } from './audit/types';
import { PLUGIN_CONFIG_PATH, RUNNER_OUTPUT_PATH } from './constants';
import { outdatedResultToAuditOutput } from './outdated/transform';
import { getTotalDependencies } from './utils';
import { findAllPackageJson, getTotalDependencies } from './utils';

export async function createRunnerConfig(
scriptPath: string,
Expand All @@ -42,7 +44,7 @@ export async function executeRunner(): Promise<void> {
packageManager,
checks,
auditLevelMapping,
packageJsonPath,
packageJsonPaths,
dependencyGroups: depGroups,
} = await readJsonFile<FinalJSPackagesPluginConfig>(PLUGIN_CONFIG_PATH);

Expand All @@ -51,7 +53,7 @@ export async function executeRunner(): Promise<void> {
: [];

const outdatedResults = checks.includes('outdated')
? await processOutdated(packageManager, depGroups, packageJsonPath)
? await processOutdated(packageManager, depGroups, packageJsonPaths)
: [];
const checkResults = [...auditResults, ...outdatedResults];

Expand All @@ -62,7 +64,7 @@ export async function executeRunner(): Promise<void> {
async function processOutdated(
id: PackageManagerId,
depGroups: DependencyGroup[],
packageJsonPath: string,
packageJsonPaths: PackageJsonPaths,
) {
const pm = packageManagers[id];
const { stdout } = await executeProcess({
Expand All @@ -72,15 +74,19 @@ async function processOutdated(
ignoreExitCode: true, // outdated returns exit code 1 when outdated dependencies are found
});

const depTotals = await getTotalDependencies(packageJsonPath);
// Locate all package.json files in the repository if not provided
const finalPaths = Array.isArray(packageJsonPaths)
? packageJsonPaths
: await findAllPackageJson();
const depTotals = await getTotalDependencies(finalPaths);

const normalizedResult = pm.outdated.unifyResult(stdout);
return depGroups.map(depGroup =>
outdatedResultToAuditOutput(
normalizedResult,
id,
depGroup,
depTotals[depGroup],
depTotals[dependencyGroupToLong[depGroup]],
),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type PackageJsonDependencies = Record<string, string>;
export type PackageJson = Partial<
Record<DependencyGroupLong, PackageJsonDependencies>
>;
export type DependencyTotals = Record<DependencyGroupLong, number>;

// Unified Outdated result type
export type OutdatedDependency = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe('createRunnerConfig', () => {
checks: ['audit'],
auditLevelMapping: defaultAuditLevelMapping,
dependencyGroups: ['prod', 'dev'],
packageJsonPath: 'package.json',
packageJsonPaths: ['package.json'],
});
expect(runnerConfig).toStrictEqual<RunnerConfig>({
command: 'node',
Expand All @@ -29,7 +29,7 @@ describe('createRunnerConfig', () => {
checks: ['outdated'],
dependencyGroups: ['prod', 'dev'],
auditLevelMapping: { ...defaultAuditLevelMapping, moderate: 'error' },
packageJsonPath: 'package.json',
packageJsonPaths: ['package.json'],
};
await createRunnerConfig('executeRunner.ts', pluginConfig);
const config = await readJsonFile<FinalJSPackagesPluginConfig>(
Expand Down
56 changes: 47 additions & 9 deletions packages/plugin-js-packages/src/lib/runner/utils.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { sep } from 'node:path';
import {
crawlFileSystem,
objectFromEntries,
objectToKeys,
readJsonFile,
} from '@code-pushup/utils';
import { dependencyGroups } from '../config';
import { dependencyGroupToLong } from '../constants';
import { AuditResult, Vulnerability } from './audit/types';
import { PackageJson } from './outdated/types';
import {
DependencyGroupLong,
DependencyTotals,
PackageJson,
dependencyGroupLong,
} from './outdated/types';

export function filterAuditResult(
result: AuditResult,
Expand Down Expand Up @@ -49,12 +54,45 @@ export function filterAuditResult(
};
}

export async function getTotalDependencies(packageJsonPath: string) {
const packageJson = await readJsonFile<PackageJson>(packageJsonPath);
// TODO: use .gitignore
export async function findAllPackageJson(): Promise<string[]> {
return (
await crawlFileSystem({
directory: '.',
pattern: /(^|[\\/])package\.json$/,
})
).filter(
path =>
!path.startsWith(`node_modules${sep}`) &&
!path.includes(`${sep}node_modules${sep}`) &&
!path.startsWith(`.nx${sep}`),
);
}

export async function getTotalDependencies(
packageJsonPaths: string[],
): Promise<DependencyTotals> {
const parsedDeps = await Promise.all(
packageJsonPaths.map(readJsonFile<PackageJson>),
);

const mergedDeps = parsedDeps.reduce<Record<DependencyGroupLong, string[]>>(
(acc, depMapper) =>
objectFromEntries(
dependencyGroupLong.map(group => {
const deps = depMapper[group];
return [
group,
[...acc[group], ...(deps == null ? [] : objectToKeys(deps))],
];
}),
),
{ dependencies: [], devDependencies: [], optionalDependencies: [] },
);
return objectFromEntries(
dependencyGroups.map(depGroup => {
const deps = packageJson[dependencyGroupToLong[depGroup]];
return [depGroup, deps == null ? 0 : objectToKeys(deps).length];
}),
objectToKeys(mergedDeps).map(deps => [
deps,
new Set(mergedDeps[deps]).size,
]),
);
}
67 changes: 59 additions & 8 deletions packages/plugin-js-packages/src/lib/runner/utils.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,36 @@ import { vol } from 'memfs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { MEMFS_VOLUME } from '@code-pushup/test-utils';
import { DependencyGroup } from '../config';
import { AuditResult, Vulnerability } from './audit/types';
import { PackageJson } from './outdated/types';
import { filterAuditResult, getTotalDependencies } from './utils';
import { DependencyTotals, PackageJson } from './outdated/types';
import {
filterAuditResult,
findAllPackageJson,
getTotalDependencies,
} from './utils';

describe('findAllPackageJson', () => {
beforeEach(() => {
vol.fromJSON(
{
'package.json': '',
[join('ui', 'package.json')]: '',
[join('ui', 'ng-package.json')]: '', // non-exact file match should be excluded
[join('.nx', 'cache', 'ui', 'package.json')]: '', // nx cache should be excluded
[join('node_modules', 'eslint', 'package.json')]: '', // root node_modules should be excluded
[join('ui', 'node_modules', 'eslint', 'package.json')]: '', // project node_modules should be excluded
},
MEMFS_VOLUME,
);
});

it('should return all valid package.json files (exclude .nx, node_modules)', async () => {
await expect(findAllPackageJson()).resolves.toEqual([
'package.json',
join('ui', 'package.json'),
]);
});
});

describe('getTotalDependencies', () => {
beforeEach(() => {
Expand All @@ -19,19 +45,44 @@ describe('getTotalDependencies', () => {
vitest: '1.3.1',
},
} satisfies PackageJson),
[join('ui', 'package.json')]: JSON.stringify({
dependencies: {
'@code-pushup/eslint-config': '1.0.0',
'@typescript-eslint/eslint-plugin': '2.0.0',
},
devDependencies: {
angular: '17.0.0',
},
optionalDependencies: {
'@esbuild/darwin-arm64': '^0.19.0',
},
} satisfies PackageJson),
},
MEMFS_VOLUME,
);
});

it('should return correct number of dependencies', async () => {
await expect(
getTotalDependencies(join(MEMFS_VOLUME, 'package.json')),
getTotalDependencies([join(MEMFS_VOLUME, 'package.json')]),
).resolves.toStrictEqual({
dependencies: 1,
devDependencies: 3,
optionalDependencies: 0,
} satisfies DependencyTotals);
});

it('should merge dependencies for multiple package.json files', async () => {
await expect(
getTotalDependencies([
join(MEMFS_VOLUME, 'package.json'),
join(MEMFS_VOLUME, 'ui', 'package.json'),
]),
).resolves.toStrictEqual({
prod: 1,
dev: 3,
optional: 0,
} satisfies Record<DependencyGroup, number>);
dependencies: 2,
devDependencies: 4,
optionalDependencies: 1,
} satisfies DependencyTotals);
});
});

Expand Down

0 comments on commit df87ff9

Please sign in to comment.