Skip to content

Commit 31d2301

Browse files
committed
refactor(plugin-js-packages): use semver for version diff
1 parent 6bc746b commit 31d2301

File tree

5 files changed

+140
-110
lines changed

5 files changed

+140
-110
lines changed

packages/plugin-js-packages/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"dependencies": {
55
"@code-pushup/models": "0.39.0",
66
"@code-pushup/utils": "0.39.0",
7+
"semver": "^7.6.0",
78
"zod": "^3.22.4"
89
}
910
}
Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1+
import type { ReleaseType } from 'semver';
12
import type { IssueSeverity } from '@code-pushup/models';
2-
import { VersionType } from './types';
3+
import { objectToKeys } from '@code-pushup/utils';
34

4-
export const outdatedSeverity: Record<VersionType, IssueSeverity> = {
5+
export const outdatedSeverity: Record<ReleaseType, IssueSeverity> = {
56
major: 'error',
7+
premajor: 'info',
68
minor: 'warning',
9+
preminor: 'info',
710
patch: 'info',
11+
prepatch: 'info',
12+
prerelease: 'info',
813
};
14+
15+
// RELEASE_TYPES directly exported from semver don't work out of the box
16+
export const RELEASE_TYPES = objectToKeys(outdatedSeverity);
Lines changed: 31 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
1-
import { Issue } from '@code-pushup/models';
2-
import { pluralize } from '@code-pushup/utils';
1+
import { ReleaseType, clean, diff, neq } from 'semver';
2+
import type { Issue } from '@code-pushup/models';
3+
import { objectFromEntries, pluralize } from '@code-pushup/utils';
34
import { DependencyGroup, PackageManagerId } from '../../config';
45
import { dependencyGroupToLong } from '../../constants';
5-
import { outdatedSeverity } from './constants';
6-
import {
7-
OutdatedResult,
8-
PackageVersion,
9-
VersionType,
10-
versionType,
11-
} from './types';
6+
import { RELEASE_TYPES, outdatedSeverity } from './constants';
7+
import { OutdatedResult } from './types';
128

139
export function outdatedResultToAuditOutput(
1410
result: OutdatedResult,
@@ -18,23 +14,30 @@ export function outdatedResultToAuditOutput(
1814
const relevantDependencies: OutdatedResult = result.filter(
1915
dep => dep.type === dependencyGroupToLong[depGroup],
2016
);
21-
// TODO use semver logic to compare versions
22-
const outdatedDependencies = relevantDependencies
23-
.filter(dep => dep.current !== dep.latest)
17+
18+
const validDependencies = relevantDependencies
19+
.map(dep => ({
20+
...dep,
21+
current: clean(dep.current),
22+
latest: clean(dep.latest),
23+
}))
2424
.filter(
25-
dep =>
26-
dep.current.split('-')[0]?.toString() !==
27-
dep.latest.split('-')[0]?.toString(),
25+
(dep): dep is OutdatedResult[number] =>
26+
dep.current != null && dep.latest != null,
2827
);
2928

30-
const outdatedStats = outdatedDependencies.reduce(
31-
(acc, dep) => {
32-
const outdatedLevel = getOutdatedLevel(dep.current, dep.latest);
33-
return { ...acc, [outdatedLevel]: acc[outdatedLevel] + 1 };
34-
},
35-
{ major: 0, minor: 0, patch: 0 },
29+
const outdatedDependencies = validDependencies.filter(dep =>
30+
neq(dep.current, dep.latest),
3631
);
3732

33+
const outdatedStats = outdatedDependencies.reduce((acc, dep) => {
34+
const outdatedLevel = diff(dep.current, dep.latest);
35+
if (outdatedLevel == null) {
36+
return acc;
37+
}
38+
return { ...acc, [outdatedLevel]: acc[outdatedLevel] + 1 };
39+
}, objectFromEntries(RELEASE_TYPES.map(versionType => [versionType, 0])));
40+
3841
const issues =
3942
outdatedDependencies.length === 0
4043
? []
@@ -59,12 +62,12 @@ export function calculateOutdatedScore(
5962
return totalDeps > 0 ? (totalDeps - majorOutdated) / totalDeps : 1;
6063
}
6164

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

65-
const versionBreakdown = versionType
66-
.map(version => (stats[version] > 0 ? `${stats[version]} ${version}` : ''))
67-
.filter(text => text !== '');
68+
const versionBreakdown = RELEASE_TYPES.map(version =>
69+
stats[version] > 0 ? `${stats[version]} ${version}` : '',
70+
).filter(text => text !== '');
6871

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

@@ -95,36 +99,3 @@ export function outdatedToIssues(dependencies: OutdatedResult): Issue[] {
9599
};
96100
});
97101
}
98-
99-
export function getOutdatedLevel(
100-
currentFullVersion: string,
101-
latestFullVersion: string,
102-
): VersionType {
103-
const current = splitPackageVersion(currentFullVersion);
104-
const latest = splitPackageVersion(latestFullVersion);
105-
106-
if (current.major < latest.major) {
107-
return 'major';
108-
}
109-
110-
if (current.minor < latest.minor) {
111-
return 'minor';
112-
}
113-
114-
if (current.patch < latest.patch) {
115-
return 'patch';
116-
}
117-
118-
throw new Error('Package is not outdated.');
119-
}
120-
121-
export function splitPackageVersion(fullVersion: string): PackageVersion {
122-
const semanticVersion = String(fullVersion.split('-')[0]);
123-
const [major, minor, patch] = semanticVersion.split('.').map(Number);
124-
125-
if (major == null || minor == null || patch == null) {
126-
throw new Error(`Invalid version description ${fullVersion}`);
127-
}
128-
129-
return { major, minor, patch };
130-
}

packages/plugin-js-packages/src/lib/runner/outdated/transform.unit.test.ts

Lines changed: 95 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import { describe, expect, it } from 'vitest';
22
import type { AuditOutput, Issue } from '@code-pushup/models';
3+
import { objectFromEntries } from '@code-pushup/utils';
4+
import { RELEASE_TYPES } from './constants';
35
import {
46
calculateOutdatedScore,
5-
getOutdatedLevel,
67
outdatedResultToAuditOutput,
78
outdatedToDisplayValue,
89
outdatedToIssues,
9-
splitPackageVersion,
1010
} from './transform';
11-
import { PackageVersion } from './types';
1211

1312
describe('outdatedResultToAuditOutput', () => {
1413
it('should create an audit output', () => {
@@ -87,7 +86,7 @@ describe('outdatedResultToAuditOutput', () => {
8786
{
8887
name: 'nx',
8988
current: '15.8.1',
90-
latest: '17.0.0-stable',
89+
latest: '17.0.0',
9190
type: 'dependencies',
9291
},
9392
{
@@ -170,20 +169,54 @@ describe('outdatedResultToAuditOutput', () => {
170169
});
171170
});
172171

173-
it('should skip identical semantic versions with different label', () => {
172+
it('should skip non-standard versions', () => {
174173
expect(
175174
outdatedResultToAuditOutput(
176175
[
177176
{
178-
name: 'cypress',
179-
current: '13.7.0-alpha',
180-
latest: '13.7.0-beta',
177+
name: 'memfs',
178+
current: '4.0.0-alpha.2',
179+
latest: 'exotic',
181180
type: 'devDependencies',
182181
},
182+
],
183+
'npm',
184+
'optional',
185+
),
186+
).toEqual<AuditOutput>({
187+
slug: 'npm-outdated-optional',
188+
score: 1,
189+
value: 0,
190+
displayValue: 'all dependencies are up to date',
191+
});
192+
});
193+
194+
it('should identify and categorise pre-release tags', () => {
195+
expect(
196+
outdatedResultToAuditOutput(
197+
[
183198
{
184-
name: 'nx',
185-
current: '17.0.0-12',
186-
latest: '17.0.0-15',
199+
name: 'esbuild',
200+
current: '0.5.3',
201+
latest: '0.6.0-alpha.1',
202+
type: 'devDependencies',
203+
},
204+
{
205+
name: 'nx-knip',
206+
current: '0.0.5-5',
207+
latest: '0.0.5-15',
208+
type: 'devDependencies',
209+
},
210+
{
211+
name: 'semver',
212+
current: '7.6.0',
213+
latest: '7.6.8-2',
214+
type: 'devDependencies',
215+
},
216+
{
217+
name: 'code-pushup',
218+
current: '0.30.0',
219+
latest: '1.0.0-alpha.1',
187220
type: 'devDependencies',
188221
},
189222
],
@@ -193,8 +226,37 @@ describe('outdatedResultToAuditOutput', () => {
193226
).toEqual<AuditOutput>({
194227
slug: 'npm-outdated-dev',
195228
score: 1,
196-
value: 0,
197-
displayValue: 'all dependencies are up to date',
229+
value: 4,
230+
displayValue:
231+
'4 outdated package versions (1 premajor, 1 preminor, 1 prepatch, 1 prerelease)',
232+
details: {
233+
issues: [
234+
{
235+
message: expect.stringContaining(
236+
'`esbuild` requires a **preminor** update',
237+
),
238+
severity: 'info',
239+
},
240+
{
241+
message: expect.stringContaining(
242+
'`nx-knip` requires a **prerelease** update',
243+
),
244+
severity: 'info',
245+
},
246+
{
247+
message: expect.stringContaining(
248+
'`semver` requires a **prepatch** update',
249+
),
250+
severity: 'info',
251+
},
252+
{
253+
message: expect.stringContaining(
254+
'`code-pushup` requires a **premajor** update',
255+
),
256+
severity: 'info',
257+
},
258+
],
259+
},
198260
});
199261
});
200262
});
@@ -210,27 +272,41 @@ describe('calculateOutdatedScore', () => {
210272
});
211273

212274
describe('outdatedToDisplayValue', () => {
275+
const ZERO_STATS = objectFromEntries(
276+
RELEASE_TYPES.map(versionType => [versionType, 0]),
277+
);
278+
213279
it('should display perfect value e for no outdated dependencies', () => {
214-
expect(outdatedToDisplayValue({ major: 0, minor: 0, patch: 0 })).toBe(
280+
expect(outdatedToDisplayValue(ZERO_STATS)).toBe(
215281
'all dependencies are up to date',
216282
);
217283
});
218284

219285
it('should explicitly state outdated dependencies', () => {
220-
expect(outdatedToDisplayValue({ major: 5, minor: 2, patch: 1 })).toBe(
221-
'8 outdated package versions (5 major, 2 minor, 1 patch)',
286+
expect(
287+
outdatedToDisplayValue({
288+
major: 5,
289+
premajor: 1,
290+
minor: 2,
291+
preminor: 2,
292+
patch: 1,
293+
prepatch: 1,
294+
prerelease: 3,
295+
}),
296+
).toBe(
297+
'15 outdated package versions (5 major, 1 premajor, 2 minor, 2 preminor, 1 patch, 1 prepatch, 3 prerelease)',
222298
);
223299
});
224300

225301
it('should only list version types that have outdated dependencies', () => {
226-
expect(outdatedToDisplayValue({ major: 2, minor: 0, patch: 3 })).toBe(
302+
expect(outdatedToDisplayValue({ ...ZERO_STATS, major: 2, patch: 3 })).toBe(
227303
'5 outdated package versions (2 major, 3 patch)',
228304
);
229305
});
230306

231307
it('should skip breakdown if only one version type is outdated', () => {
232-
expect(outdatedToDisplayValue({ major: 0, minor: 4, patch: 0 })).toBe(
233-
'4 minor outdated package versions',
308+
expect(outdatedToDisplayValue({ ...ZERO_STATS, prerelease: 4 })).toBe(
309+
'4 prerelease outdated package versions',
234310
);
235311
});
236312
});
@@ -294,29 +370,3 @@ describe('outdatedToIssues', () => {
294370
expect(outdatedToIssues([])).toEqual([]);
295371
});
296372
});
297-
298-
describe('getOutdatedLevel', () => {
299-
it('should return outdated major version', () => {
300-
expect(getOutdatedLevel('4.2.1', '5.2.0')).toBe('major');
301-
});
302-
303-
it('should prioritise higher outdated version level', () => {
304-
expect(getOutdatedLevel('6.2.1', '6.3.2')).toBe('minor');
305-
});
306-
});
307-
308-
describe('splitPackageVersion', () => {
309-
it('should split version into major, minor and patch', () => {
310-
expect(splitPackageVersion('0.32.4')).toEqual<PackageVersion>({
311-
major: 0,
312-
minor: 32,
313-
patch: 4,
314-
});
315-
});
316-
317-
it('should throw for an incomplete version', () => {
318-
expect(() => splitPackageVersion('5.0')).toThrow(
319-
'Invalid version description 5.0',
320-
);
321-
});
322-
});

packages/plugin-js-packages/src/lib/runner/outdated/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
export const versionType = ['major', 'minor', 'patch'] as const;
2-
export type VersionType = (typeof versionType)[number];
3-
export type PackageVersion = Record<VersionType, number>;
1+
import type { ReleaseType } from 'semver';
2+
3+
export type PackageVersion = Record<ReleaseType, number>;
44
export type DependencyGroupLong =
55
| 'dependencies'
66
| 'devDependencies'

0 commit comments

Comments
 (0)