Skip to content

Commit

Permalink
refactor(plugin-js-packages): use semver for version diff
Browse files Browse the repository at this point in the history
  • Loading branch information
Tlacenka committed Apr 30, 2024
1 parent 6bc746b commit 31d2301
Show file tree
Hide file tree
Showing 5 changed files with 140 additions and 110 deletions.
1 change: 1 addition & 0 deletions packages/plugin-js-packages/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"dependencies": {
"@code-pushup/models": "0.39.0",
"@code-pushup/utils": "0.39.0",
"semver": "^7.6.0",
"zod": "^3.22.4"
}
}
12 changes: 10 additions & 2 deletions packages/plugin-js-packages/src/lib/runner/outdated/constants.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import type { ReleaseType } from 'semver';
import type { IssueSeverity } from '@code-pushup/models';
import { VersionType } from './types';
import { objectToKeys } from '@code-pushup/utils';

export const outdatedSeverity: Record<VersionType, IssueSeverity> = {
export const outdatedSeverity: Record<ReleaseType, IssueSeverity> = {
major: 'error',
premajor: 'info',
minor: 'warning',
preminor: 'info',
patch: 'info',
prepatch: 'info',
prerelease: 'info',
};

// RELEASE_TYPES directly exported from semver don't work out of the box
export const RELEASE_TYPES = objectToKeys(outdatedSeverity);
91 changes: 31 additions & 60 deletions packages/plugin-js-packages/src/lib/runner/outdated/transform.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import { Issue } from '@code-pushup/models';
import { pluralize } from '@code-pushup/utils';
import { ReleaseType, clean, diff, neq } from 'semver';
import type { Issue } from '@code-pushup/models';
import { objectFromEntries, pluralize } from '@code-pushup/utils';
import { DependencyGroup, PackageManagerId } from '../../config';
import { dependencyGroupToLong } from '../../constants';
import { outdatedSeverity } from './constants';
import {
OutdatedResult,
PackageVersion,
VersionType,
versionType,
} from './types';
import { RELEASE_TYPES, outdatedSeverity } from './constants';
import { OutdatedResult } from './types';

export function outdatedResultToAuditOutput(
result: OutdatedResult,
Expand All @@ -18,23 +14,30 @@ export function outdatedResultToAuditOutput(
const relevantDependencies: OutdatedResult = result.filter(
dep => dep.type === dependencyGroupToLong[depGroup],
);
// TODO use semver logic to compare versions
const outdatedDependencies = relevantDependencies
.filter(dep => dep.current !== dep.latest)

const validDependencies = relevantDependencies
.map(dep => ({
...dep,
current: clean(dep.current),
latest: clean(dep.latest),
}))
.filter(
dep =>
dep.current.split('-')[0]?.toString() !==
dep.latest.split('-')[0]?.toString(),
(dep): dep is OutdatedResult[number] =>
dep.current != null && dep.latest != null,
);

const outdatedStats = outdatedDependencies.reduce(
(acc, dep) => {
const outdatedLevel = getOutdatedLevel(dep.current, dep.latest);
return { ...acc, [outdatedLevel]: acc[outdatedLevel] + 1 };
},
{ major: 0, minor: 0, patch: 0 },
const outdatedDependencies = validDependencies.filter(dep =>
neq(dep.current, dep.latest),
);

const outdatedStats = outdatedDependencies.reduce((acc, dep) => {
const outdatedLevel = diff(dep.current, dep.latest);
if (outdatedLevel == null) {
return acc;
}
return { ...acc, [outdatedLevel]: acc[outdatedLevel] + 1 };
}, objectFromEntries(RELEASE_TYPES.map(versionType => [versionType, 0])));

const issues =
outdatedDependencies.length === 0
? []
Expand All @@ -59,12 +62,12 @@ export function calculateOutdatedScore(
return totalDeps > 0 ? (totalDeps - majorOutdated) / totalDeps : 1;
}

export function outdatedToDisplayValue(stats: Record<VersionType, number>) {
const total = stats.major + stats.minor + stats.patch;
export function outdatedToDisplayValue(stats: Record<ReleaseType, number>) {
const total = Object.values(stats).reduce((acc, value) => acc + value, 0);

const versionBreakdown = versionType
.map(version => (stats[version] > 0 ? `${stats[version]} ${version}` : ''))
.filter(text => text !== '');
const versionBreakdown = RELEASE_TYPES.map(version =>
stats[version] > 0 ? `${stats[version]} ${version}` : '',
).filter(text => text !== '');

if (versionBreakdown.length === 0) {
return 'all dependencies are up to date';
Expand All @@ -85,7 +88,8 @@ export function outdatedToDisplayValue(stats: Record<VersionType, number>) {
export function outdatedToIssues(dependencies: OutdatedResult): Issue[] {
return dependencies.map<Issue>(dep => {
const { name, current, latest, url } = dep;
const outdatedLevel = getOutdatedLevel(current, latest);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const outdatedLevel = diff(current, latest)!;
const packageReference =
url == null ? `\`${name}\`` : `[\`${name}\`](${url})`;

Expand All @@ -95,36 +99,3 @@ export function outdatedToIssues(dependencies: OutdatedResult): Issue[] {
};
});
}

export function getOutdatedLevel(
currentFullVersion: string,
latestFullVersion: string,
): VersionType {
const current = splitPackageVersion(currentFullVersion);
const latest = splitPackageVersion(latestFullVersion);

if (current.major < latest.major) {
return 'major';
}

if (current.minor < latest.minor) {
return 'minor';
}

if (current.patch < latest.patch) {
return 'patch';
}

throw new Error('Package is not outdated.');
}

export function splitPackageVersion(fullVersion: string): PackageVersion {
const semanticVersion = String(fullVersion.split('-')[0]);
const [major, minor, patch] = semanticVersion.split('.').map(Number);

if (major == null || minor == null || patch == null) {
throw new Error(`Invalid version description ${fullVersion}`);
}

return { major, minor, patch };
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { describe, expect, it } from 'vitest';
import type { AuditOutput, Issue } from '@code-pushup/models';
import { objectFromEntries } from '@code-pushup/utils';
import { RELEASE_TYPES } from './constants';
import {
calculateOutdatedScore,
getOutdatedLevel,
outdatedResultToAuditOutput,
outdatedToDisplayValue,
outdatedToIssues,
splitPackageVersion,
} from './transform';
import { PackageVersion } from './types';

describe('outdatedResultToAuditOutput', () => {
it('should create an audit output', () => {
Expand Down Expand Up @@ -87,7 +86,7 @@ describe('outdatedResultToAuditOutput', () => {
{
name: 'nx',
current: '15.8.1',
latest: '17.0.0-stable',
latest: '17.0.0',
type: 'dependencies',
},
{
Expand Down Expand Up @@ -170,20 +169,54 @@ describe('outdatedResultToAuditOutput', () => {
});
});

it('should skip identical semantic versions with different label', () => {
it('should skip non-standard versions', () => {
expect(
outdatedResultToAuditOutput(
[
{
name: 'cypress',
current: '13.7.0-alpha',
latest: '13.7.0-beta',
name: 'memfs',
current: '4.0.0-alpha.2',
latest: 'exotic',
type: 'devDependencies',
},
],
'npm',
'optional',
),
).toEqual<AuditOutput>({
slug: 'npm-outdated-optional',
score: 1,
value: 0,
displayValue: 'all dependencies are up to date',
});
});

it('should identify and categorise pre-release tags', () => {
expect(
outdatedResultToAuditOutput(
[
{
name: 'nx',
current: '17.0.0-12',
latest: '17.0.0-15',
name: 'esbuild',
current: '0.5.3',
latest: '0.6.0-alpha.1',
type: 'devDependencies',
},
{
name: 'nx-knip',
current: '0.0.5-5',
latest: '0.0.5-15',
type: 'devDependencies',
},
{
name: 'semver',
current: '7.6.0',
latest: '7.6.8-2',
type: 'devDependencies',
},
{
name: 'code-pushup',
current: '0.30.0',
latest: '1.0.0-alpha.1',
type: 'devDependencies',
},
],
Expand All @@ -193,8 +226,37 @@ describe('outdatedResultToAuditOutput', () => {
).toEqual<AuditOutput>({
slug: 'npm-outdated-dev',
score: 1,
value: 0,
displayValue: 'all dependencies are up to date',
value: 4,
displayValue:
'4 outdated package versions (1 premajor, 1 preminor, 1 prepatch, 1 prerelease)',
details: {
issues: [
{
message: expect.stringContaining(
'`esbuild` requires a **preminor** update',
),
severity: 'info',
},
{
message: expect.stringContaining(
'`nx-knip` requires a **prerelease** update',
),
severity: 'info',
},
{
message: expect.stringContaining(
'`semver` requires a **prepatch** update',
),
severity: 'info',
},
{
message: expect.stringContaining(
'`code-pushup` requires a **premajor** update',
),
severity: 'info',
},
],
},
});
});
});
Expand All @@ -210,27 +272,41 @@ describe('calculateOutdatedScore', () => {
});

describe('outdatedToDisplayValue', () => {
const ZERO_STATS = objectFromEntries(
RELEASE_TYPES.map(versionType => [versionType, 0]),
);

it('should display perfect value e for no outdated dependencies', () => {
expect(outdatedToDisplayValue({ major: 0, minor: 0, patch: 0 })).toBe(
expect(outdatedToDisplayValue(ZERO_STATS)).toBe(
'all dependencies are up to date',
);
});

it('should explicitly state outdated dependencies', () => {
expect(outdatedToDisplayValue({ major: 5, minor: 2, patch: 1 })).toBe(
'8 outdated package versions (5 major, 2 minor, 1 patch)',
expect(
outdatedToDisplayValue({
major: 5,
premajor: 1,
minor: 2,
preminor: 2,
patch: 1,
prepatch: 1,
prerelease: 3,
}),
).toBe(
'15 outdated package versions (5 major, 1 premajor, 2 minor, 2 preminor, 1 patch, 1 prepatch, 3 prerelease)',
);
});

it('should only list version types that have outdated dependencies', () => {
expect(outdatedToDisplayValue({ major: 2, minor: 0, patch: 3 })).toBe(
expect(outdatedToDisplayValue({ ...ZERO_STATS, major: 2, patch: 3 })).toBe(
'5 outdated package versions (2 major, 3 patch)',
);
});

it('should skip breakdown if only one version type is outdated', () => {
expect(outdatedToDisplayValue({ major: 0, minor: 4, patch: 0 })).toBe(
'4 minor outdated package versions',
expect(outdatedToDisplayValue({ ...ZERO_STATS, prerelease: 4 })).toBe(
'4 prerelease outdated package versions',
);
});
});
Expand Down Expand Up @@ -294,29 +370,3 @@ describe('outdatedToIssues', () => {
expect(outdatedToIssues([])).toEqual([]);
});
});

describe('getOutdatedLevel', () => {
it('should return outdated major version', () => {
expect(getOutdatedLevel('4.2.1', '5.2.0')).toBe('major');
});

it('should prioritise higher outdated version level', () => {
expect(getOutdatedLevel('6.2.1', '6.3.2')).toBe('minor');
});
});

describe('splitPackageVersion', () => {
it('should split version into major, minor and patch', () => {
expect(splitPackageVersion('0.32.4')).toEqual<PackageVersion>({
major: 0,
minor: 32,
patch: 4,
});
});

it('should throw for an incomplete version', () => {
expect(() => splitPackageVersion('5.0')).toThrow(
'Invalid version description 5.0',
);
});
});
6 changes: 3 additions & 3 deletions packages/plugin-js-packages/src/lib/runner/outdated/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export const versionType = ['major', 'minor', 'patch'] as const;
export type VersionType = (typeof versionType)[number];
export type PackageVersion = Record<VersionType, number>;
import type { ReleaseType } from 'semver';

export type PackageVersion = Record<ReleaseType, number>;
export type DependencyGroupLong =
| 'dependencies'
| 'devDependencies'
Expand Down

0 comments on commit 31d2301

Please sign in to comment.