Skip to content

Commit a17e423

Browse files
committed
feat(semver): support resolving with lowest version
Closes #110
1 parent b5ceae0 commit a17e423

File tree

14 files changed

+336
-41
lines changed

14 files changed

+336
-41
lines changed

site/docs/config/version-groups.mdx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,25 @@ that the dependency is not present in the earlier packages in the array.
229229
]
230230
}
231231
```
232+
233+
## `preferVersion` string
234+
235+
<Pills optional />
236+
237+
Defaults to `highestSemver` but can be optionally changed to `lowestSemver`.
238+
239+
To set this as your standard policy, create a version group which applies to
240+
every dependency as the last item in your `versionGroups` array. You can also
241+
just set this for some of the packages if you need to.
242+
243+
```json title="Choose the lowest valid semver version when fixing mismatches"
244+
{
245+
"versionGroups": [
246+
{
247+
"dependencies": ["**"],
248+
"packages": ["**"],
249+
"preferVersion": "lowestSemver"
250+
}
251+
]
252+
}
253+
```

src/bin-list-mismatches/list-mismatches.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export function listMismatches(ctx: Syncpack.Ctx): Syncpack.Ctx {
3333
if (instanceGroup.hasWorkspaceInstance()) {
3434
return logWorkspaceMismatch(instanceGroup);
3535
}
36-
logHighestVersionMismatch(instanceGroup);
36+
logHighLowVersionMismatch(instanceGroup);
3737
});
3838
});
3939

@@ -109,12 +109,17 @@ export function listMismatches(ctx: Syncpack.Ctx): Syncpack.Ctx {
109109
});
110110
}
111111

112-
function logHighestVersionMismatch(instanceGroup: InstanceGroup) {
112+
function logHighLowVersionMismatch(instanceGroup: InstanceGroup) {
113113
const name = instanceGroup.name;
114+
const preference = (
115+
instanceGroup.versionGroup
116+
.groupConfig as Syncpack.Config.VersionGroup.Standard
117+
).preferVersion;
118+
const direction = preference === 'highestSemver' ? 'highest' : 'lowest';
114119
const expected = R.getExn(instanceGroup.getExpectedVersion());
115120
log.invalid(
116121
name,
117-
chalk`{reset.green ${expected}} {dim is the highest valid semver version in use}`,
122+
chalk`{reset.green ${expected}} {dim is the ${direction} valid semver version in use}`,
118123
);
119124
// Log each of the dependencies mismatches
120125
instanceGroup.instances.forEach((instance) => {

src/get-context/get-config/schema/index.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { z } from 'zod';
22
import { DEFAULT_CONFIG } from '../../../constants';
3+
import { nonEmptyString } from './lib/non-empty-string';
34
import * as paths from './paths';
45
import * as semverGroup from './semver-group';
56
import * as semverRange from './semver-range';
67
import * as versionGroup from './version-group';
78

8-
const nonEmptyString = z.string().trim().min(1);
9-
109
const cliOnly = {
1110
configPath: z.string().optional(),
1211
types: z.string().default(''),
@@ -36,7 +35,7 @@ const privateOnly = {
3635
allTypes: z.array(paths.pathDefinition),
3736
enabledTypes: z.array(paths.pathDefinition),
3837
defaultSemverGroup: semverGroup.base,
39-
defaultVersionGroup: versionGroup.base,
38+
defaultVersionGroup: versionGroup.defaultGroup,
4039
} as const;
4140

4241
export const Private = z.object({
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { z } from 'zod';
2+
3+
export const nonEmptyString = z.string().trim().min(1);

src/get-context/get-config/schema/version-group.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import { z } from 'zod';
22
import { baseGroupFields } from './base-group';
3+
import { nonEmptyString } from './lib/non-empty-string';
34

4-
const nonEmptyString = z.string().trim().min(1);
5+
const preferVersion = z
6+
.enum(['highestSemver', 'lowestSemver'])
7+
.optional()
8+
.default('highestSemver');
59

6-
export const standard = z.object(baseGroupFields).strict();
10+
export const standard = z
11+
.object({ ...baseGroupFields, preferVersion })
12+
.strict();
713

814
export const banned = z
915
.object({ ...baseGroupFields, isBanned: z.literal(true) })
@@ -21,8 +27,8 @@ export const snappedTo = z
2127
.object({ ...baseGroupFields, snapTo: z.array(nonEmptyString) })
2228
.strict();
2329

24-
export const base = z
25-
.object({ ...baseGroupFields, isDefault: z.literal(true) })
30+
export const defaultGroup = z
31+
.object({ ...baseGroupFields, isDefault: z.literal(true), preferVersion })
2632
.strict();
2733

2834
export const any = z.union([
@@ -31,5 +37,5 @@ export const any = z.union([
3137
ignored,
3238
pinned,
3339
snappedTo,
34-
base,
40+
defaultGroup,
3541
]);
Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,60 @@
11
import { R } from '@mobily/ts-belt';
22
import 'expect-more-jest';
3+
import { shuffle } from '../../../../../test/shuffle';
34
import { getHighestVersion } from './get-highest-version';
45

5-
const shuffle = (array: string[]): string[] => {
6-
for (let i = array.length - 1; i > 0; i--) {
7-
const j = Math.floor(Math.random() * (i + 1));
8-
[array[i], array[j]] = [array[j], array[i]];
9-
}
10-
return array;
11-
};
12-
136
describe('getHighestVersion', () => {
14-
it('returns the newest version from an array of versions', () => {
15-
const a = ['<1.0.0'];
16-
const b = shuffle([...a, '<=1.0.0']);
17-
const c = shuffle([...b, '1']);
18-
const d = shuffle([...c, '1.0.0']);
19-
const e = shuffle([...d, '~1.0.0']);
20-
const f = shuffle([...e, '1.x.x']);
21-
const g = shuffle([...f, '^1.0.0']);
22-
const h = shuffle([...g, '>=1.0.0']);
23-
const i = shuffle([...h, '>1.0.0']);
24-
const j = shuffle([...i, '*']);
25-
// valid semver
7+
const a = ['<1.0.0'];
8+
const b = shuffle([...a, '<=1.0.0']);
9+
const c = shuffle([...b, '1']);
10+
const d = shuffle([...c, '1.0.0']);
11+
const e = shuffle([...d, '~1.0.0']);
12+
const f = shuffle([...e, '1.x.x']);
13+
const g = shuffle([...f, '^1.0.0']);
14+
const h = shuffle([...g, '>=1.0.0']);
15+
const i = shuffle([...h, '>1.0.0']);
16+
const j = shuffle([...i, '*']);
17+
18+
// "1" and "1.0.0" are equal and first match wins
19+
const eitherFormat = expect.stringMatching(/^(1|1\.0\.0)$/);
20+
21+
it('returns "<1.0.0" when it is the only version', () => {
2622
expect(getHighestVersion(a)).toEqual(R.Ok('<1.0.0'));
23+
});
24+
25+
it('returns "<=1.0.0" when added', () => {
2726
expect(getHighestVersion(b)).toEqual(R.Ok('<=1.0.0'));
27+
});
28+
29+
it('returns "1" when added', () => {
2830
expect(getHighestVersion(c)).toEqual(R.Ok('1'));
29-
expect(getHighestVersion(d)).toEqual(
30-
// "1" and "1.0.0" are equal and first match wins
31-
R.Ok(expect.stringMatching(/^(1|1\.0\.0)$/)),
32-
);
31+
});
32+
33+
it('returns "1.0.0" when added', () => {
34+
expect(getHighestVersion(d)).toEqual(R.Ok(eitherFormat));
35+
});
36+
37+
it('returns "~1.0.0" when added', () => {
3338
expect(getHighestVersion(e)).toEqual(R.Ok('~1.0.0'));
39+
});
40+
41+
it('returns "1.x.x" when added', () => {
3442
expect(getHighestVersion(f)).toEqual(R.Ok('1.x.x'));
43+
});
44+
45+
it('returns "^1.0.0" when added', () => {
3546
expect(getHighestVersion(g)).toEqual(R.Ok('^1.0.0'));
47+
});
48+
49+
it('returns ">=1.0.0" when added', () => {
3650
expect(getHighestVersion(h)).toEqual(R.Ok('>=1.0.0'));
51+
});
52+
53+
it('returns ">1.0.0" when added', () => {
3754
expect(getHighestVersion(i)).toEqual(R.Ok('>1.0.0'));
55+
});
56+
57+
it('returns "*" when added', () => {
3858
expect(getHighestVersion(j)).toEqual(R.Ok('*'));
3959
});
4060
});

src/get-context/get-groups/version-group/instance-group/get-highest-version.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { R } from '@mobily/ts-belt';
22
import { BaseError } from '../../../../lib/error';
33
import { clean } from './lib/clean';
4-
import { compareSemver } from './lib/compare-semver';
4+
import { compareGt } from './lib/compare-semver';
55
import { getRangeScore } from './lib/get-range-score';
66

77
interface HighestVersion {
@@ -15,7 +15,7 @@ export function getHighestVersion(
1515
let highest: HighestVersion | undefined;
1616

1717
for (const withRange of versions) {
18-
switch (compareSemver(withRange, highest?.semver)) {
18+
switch (compareGt(withRange, highest?.semver)) {
1919
// highest possible, quit early
2020
case '*': {
2121
return R.Ok(withRange);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { R } from '@mobily/ts-belt';
2+
import 'expect-more-jest';
3+
import { shuffle } from '../../../../../test/shuffle';
4+
import { getLowestVersion } from './get-lowest-version';
5+
6+
describe('getLowestVersion', () => {
7+
const a = ['*'];
8+
const b = shuffle([...a, '>1.0.0']);
9+
const c = shuffle([...b, '>=1.0.0']);
10+
const d = shuffle([...c, '^1.0.0']);
11+
const e = shuffle([...d, '1.x.x']);
12+
const f = shuffle([...e, '~1.0.0']);
13+
const g = shuffle([...f, '1.0.0']);
14+
const h = shuffle([...g, '1']);
15+
const i = shuffle([...h, '<=1.0.0']);
16+
const j = shuffle([...i, '<1.0.0']);
17+
18+
// "1" and "1.0.0" are equal and first match wins
19+
const eitherFormat = expect.stringMatching(/^(1|1\.0\.0)$/);
20+
21+
it('returns "*" when it is the only version', () => {
22+
expect(getLowestVersion(a)).toEqual(R.Ok('*'));
23+
});
24+
25+
it('returns ">1.0.0" when added', () => {
26+
expect(getLowestVersion(b)).toEqual(R.Ok('>1.0.0'));
27+
});
28+
29+
it('returns ">=1.0.0" when added', () => {
30+
expect(getLowestVersion(c)).toEqual(R.Ok('>=1.0.0'));
31+
});
32+
33+
it('returns "^1.0.0" when added', () => {
34+
expect(getLowestVersion(d)).toEqual(R.Ok('^1.0.0'));
35+
});
36+
37+
it('returns "1.x.x" when added', () => {
38+
expect(getLowestVersion(e)).toEqual(R.Ok('1.x.x'));
39+
});
40+
41+
it('returns "~1.0.0" when added', () => {
42+
expect(getLowestVersion(f)).toEqual(R.Ok('~1.0.0'));
43+
});
44+
45+
it('returns "1.0.0" when added', () => {
46+
expect(getLowestVersion(g)).toEqual(R.Ok('1.0.0'));
47+
});
48+
49+
it('returns "1" when added', () => {
50+
expect(getLowestVersion(h)).toEqual(R.Ok(eitherFormat));
51+
});
52+
53+
it('returns "<=1.0.0" when added', () => {
54+
expect(getLowestVersion(i)).toEqual(R.Ok('<=1.0.0'));
55+
});
56+
57+
it('returns "<1.0.0" when added', () => {
58+
expect(getLowestVersion(j)).toEqual(R.Ok('<1.0.0'));
59+
});
60+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { R } from '@mobily/ts-belt';
2+
import { BaseError } from '../../../../lib/error';
3+
import { clean } from './lib/clean';
4+
import { compareLt } from './lib/compare-semver';
5+
import { getRangeScore } from './lib/get-range-score';
6+
7+
interface LowestVersion {
8+
withRange: string;
9+
semver: string;
10+
}
11+
12+
export function getLowestVersion(
13+
versions: string[],
14+
): R.Result<string, BaseError> {
15+
let lowest: LowestVersion | undefined;
16+
17+
for (const withRange of versions) {
18+
switch (compareLt(withRange, lowest?.semver)) {
19+
// lowest possible, quit early
20+
case '*': {
21+
if (!lowest) lowest = { withRange: '*', semver: '*' };
22+
continue;
23+
}
24+
// impossible to know how the user wants to resolve unsupported versions
25+
case 'invalid': {
26+
return R.Error(new BaseError(`"${withRange}" is not supported`));
27+
}
28+
// we found a new lowest version
29+
case 'lt': {
30+
lowest = newLowestVersion(withRange);
31+
continue;
32+
}
33+
// versions are the same, but one range might be greedier than another
34+
case 'eq': {
35+
const score = getRangeScore(withRange);
36+
const lowestScore = getRangeScore(`${lowest?.withRange}`);
37+
if (score < lowestScore) lowest = newLowestVersion(withRange);
38+
}
39+
}
40+
}
41+
42+
return lowest && lowest.withRange
43+
? R.Ok(lowest.withRange)
44+
: R.Error(new BaseError(`getLowestVersion(): did not return a version`));
45+
}
46+
47+
function newLowestVersion(withRange: string): LowestVersion {
48+
return { withRange, semver: clean(withRange) };
49+
}

src/get-context/get-groups/version-group/instance-group/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@ import type { VersionGroup } from '..';
44
import { BaseError } from '../../../../lib/error';
55
import { isSemver } from '../../../../lib/is-semver';
66
import { printStrings } from '../../../../lib/print-strings';
7+
import type { Syncpack } from '../../../../types';
78
import { props } from '../../../get-package-json-files/get-patterns/props';
89
import type { Instance } from '../../../get-package-json-files/package-json-file/instance';
910
import { getHighestVersion } from './get-highest-version';
11+
import { getLowestVersion } from './get-lowest-version';
12+
13+
type Standard = Syncpack.Config.VersionGroup.Standard;
1014

1115
export const DELETE = Symbol('DELETE');
1216
export type Delete = typeof DELETE;
@@ -70,14 +74,22 @@ export class InstanceGroup {
7074
),
7175
);
7276
}
73-
return this.getHighestVersion();
77+
return (versionGroup.groupConfig as Standard).preferVersion ===
78+
'lowestSemver'
79+
? this.getLowestVersion()
80+
: this.getHighestVersion();
7481
}
7582

7683
/** If all versions are valid semver, return the newest one */
7784
getHighestVersion(): R.Result<string, BaseError> {
7885
return getHighestVersion(this.getUniqueVersions());
7986
}
8087

88+
/** If all versions are valid semver, return the lowest one */
89+
getLowestVersion(): R.Result<string, BaseError> {
90+
return getLowestVersion(this.getUniqueVersions());
91+
}
92+
8193
/** Get the first version matched by the `snapTo` packages */
8294
getSnappedVersion(): R.Result<string, BaseError> {
8395
return pipe(

0 commit comments

Comments
 (0)