Skip to content

Commit

Permalink
feat(semver): support resolving with lowest version
Browse files Browse the repository at this point in the history
Closes #110
  • Loading branch information
JamieMason committed Feb 21, 2023
1 parent b5ceae0 commit a17e423
Show file tree
Hide file tree
Showing 14 changed files with 336 additions and 41 deletions.
22 changes: 22 additions & 0 deletions site/docs/config/version-groups.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,25 @@ that the dependency is not present in the earlier packages in the array.
]
}
```

## `preferVersion` string

<Pills optional />

Defaults to `highestSemver` but can be optionally changed to `lowestSemver`.

To set this as your standard policy, create a version group which applies to
every dependency as the last item in your `versionGroups` array. You can also
just set this for some of the packages if you need to.

```json title="Choose the lowest valid semver version when fixing mismatches"
{
"versionGroups": [
{
"dependencies": ["**"],
"packages": ["**"],
"preferVersion": "lowestSemver"
}
]
}
```
11 changes: 8 additions & 3 deletions src/bin-list-mismatches/list-mismatches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function listMismatches(ctx: Syncpack.Ctx): Syncpack.Ctx {
if (instanceGroup.hasWorkspaceInstance()) {
return logWorkspaceMismatch(instanceGroup);
}
logHighestVersionMismatch(instanceGroup);
logHighLowVersionMismatch(instanceGroup);
});
});

Expand Down Expand Up @@ -109,12 +109,17 @@ export function listMismatches(ctx: Syncpack.Ctx): Syncpack.Ctx {
});
}

function logHighestVersionMismatch(instanceGroup: InstanceGroup) {
function logHighLowVersionMismatch(instanceGroup: InstanceGroup) {
const name = instanceGroup.name;
const preference = (
instanceGroup.versionGroup
.groupConfig as Syncpack.Config.VersionGroup.Standard
).preferVersion;
const direction = preference === 'highestSemver' ? 'highest' : 'lowest';
const expected = R.getExn(instanceGroup.getExpectedVersion());
log.invalid(
name,
chalk`{reset.green ${expected}} {dim is the highest valid semver version in use}`,
chalk`{reset.green ${expected}} {dim is the ${direction} valid semver version in use}`,
);
// Log each of the dependencies mismatches
instanceGroup.instances.forEach((instance) => {
Expand Down
5 changes: 2 additions & 3 deletions src/get-context/get-config/schema/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { z } from 'zod';
import { DEFAULT_CONFIG } from '../../../constants';
import { nonEmptyString } from './lib/non-empty-string';
import * as paths from './paths';
import * as semverGroup from './semver-group';
import * as semverRange from './semver-range';
import * as versionGroup from './version-group';

const nonEmptyString = z.string().trim().min(1);

const cliOnly = {
configPath: z.string().optional(),
types: z.string().default(''),
Expand Down Expand Up @@ -36,7 +35,7 @@ const privateOnly = {
allTypes: z.array(paths.pathDefinition),
enabledTypes: z.array(paths.pathDefinition),
defaultSemverGroup: semverGroup.base,
defaultVersionGroup: versionGroup.base,
defaultVersionGroup: versionGroup.defaultGroup,
} as const;

export const Private = z.object({
Expand Down
3 changes: 3 additions & 0 deletions src/get-context/get-config/schema/lib/non-empty-string.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { z } from 'zod';

export const nonEmptyString = z.string().trim().min(1);
16 changes: 11 additions & 5 deletions src/get-context/get-config/schema/version-group.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { z } from 'zod';
import { baseGroupFields } from './base-group';
import { nonEmptyString } from './lib/non-empty-string';

const nonEmptyString = z.string().trim().min(1);
const preferVersion = z
.enum(['highestSemver', 'lowestSemver'])
.optional()
.default('highestSemver');

export const standard = z.object(baseGroupFields).strict();
export const standard = z
.object({ ...baseGroupFields, preferVersion })
.strict();

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

export const base = z
.object({ ...baseGroupFields, isDefault: z.literal(true) })
export const defaultGroup = z
.object({ ...baseGroupFields, isDefault: z.literal(true), preferVersion })
.strict();

export const any = z.union([
Expand All @@ -31,5 +37,5 @@ export const any = z.union([
ignored,
pinned,
snappedTo,
base,
defaultGroup,
]);
Original file line number Diff line number Diff line change
@@ -1,40 +1,60 @@
import { R } from '@mobily/ts-belt';
import 'expect-more-jest';
import { shuffle } from '../../../../../test/shuffle';
import { getHighestVersion } from './get-highest-version';

const shuffle = (array: string[]): string[] => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
};

describe('getHighestVersion', () => {
it('returns the newest version from an array of versions', () => {
const a = ['<1.0.0'];
const b = shuffle([...a, '<=1.0.0']);
const c = shuffle([...b, '1']);
const d = shuffle([...c, '1.0.0']);
const e = shuffle([...d, '~1.0.0']);
const f = shuffle([...e, '1.x.x']);
const g = shuffle([...f, '^1.0.0']);
const h = shuffle([...g, '>=1.0.0']);
const i = shuffle([...h, '>1.0.0']);
const j = shuffle([...i, '*']);
// valid semver
const a = ['<1.0.0'];
const b = shuffle([...a, '<=1.0.0']);
const c = shuffle([...b, '1']);
const d = shuffle([...c, '1.0.0']);
const e = shuffle([...d, '~1.0.0']);
const f = shuffle([...e, '1.x.x']);
const g = shuffle([...f, '^1.0.0']);
const h = shuffle([...g, '>=1.0.0']);
const i = shuffle([...h, '>1.0.0']);
const j = shuffle([...i, '*']);

// "1" and "1.0.0" are equal and first match wins
const eitherFormat = expect.stringMatching(/^(1|1\.0\.0)$/);

it('returns "<1.0.0" when it is the only version', () => {
expect(getHighestVersion(a)).toEqual(R.Ok('<1.0.0'));
});

it('returns "<=1.0.0" when added', () => {
expect(getHighestVersion(b)).toEqual(R.Ok('<=1.0.0'));
});

it('returns "1" when added', () => {
expect(getHighestVersion(c)).toEqual(R.Ok('1'));
expect(getHighestVersion(d)).toEqual(
// "1" and "1.0.0" are equal and first match wins
R.Ok(expect.stringMatching(/^(1|1\.0\.0)$/)),
);
});

it('returns "1.0.0" when added', () => {
expect(getHighestVersion(d)).toEqual(R.Ok(eitherFormat));
});

it('returns "~1.0.0" when added', () => {
expect(getHighestVersion(e)).toEqual(R.Ok('~1.0.0'));
});

it('returns "1.x.x" when added', () => {
expect(getHighestVersion(f)).toEqual(R.Ok('1.x.x'));
});

it('returns "^1.0.0" when added', () => {
expect(getHighestVersion(g)).toEqual(R.Ok('^1.0.0'));
});

it('returns ">=1.0.0" when added', () => {
expect(getHighestVersion(h)).toEqual(R.Ok('>=1.0.0'));
});

it('returns ">1.0.0" when added', () => {
expect(getHighestVersion(i)).toEqual(R.Ok('>1.0.0'));
});

it('returns "*" when added', () => {
expect(getHighestVersion(j)).toEqual(R.Ok('*'));
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { R } from '@mobily/ts-belt';
import { BaseError } from '../../../../lib/error';
import { clean } from './lib/clean';
import { compareSemver } from './lib/compare-semver';
import { compareGt } from './lib/compare-semver';
import { getRangeScore } from './lib/get-range-score';

interface HighestVersion {
Expand All @@ -15,7 +15,7 @@ export function getHighestVersion(
let highest: HighestVersion | undefined;

for (const withRange of versions) {
switch (compareSemver(withRange, highest?.semver)) {
switch (compareGt(withRange, highest?.semver)) {
// highest possible, quit early
case '*': {
return R.Ok(withRange);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { R } from '@mobily/ts-belt';
import 'expect-more-jest';
import { shuffle } from '../../../../../test/shuffle';
import { getLowestVersion } from './get-lowest-version';

describe('getLowestVersion', () => {
const a = ['*'];
const b = shuffle([...a, '>1.0.0']);
const c = shuffle([...b, '>=1.0.0']);
const d = shuffle([...c, '^1.0.0']);
const e = shuffle([...d, '1.x.x']);
const f = shuffle([...e, '~1.0.0']);
const g = shuffle([...f, '1.0.0']);
const h = shuffle([...g, '1']);
const i = shuffle([...h, '<=1.0.0']);
const j = shuffle([...i, '<1.0.0']);

// "1" and "1.0.0" are equal and first match wins
const eitherFormat = expect.stringMatching(/^(1|1\.0\.0)$/);

it('returns "*" when it is the only version', () => {
expect(getLowestVersion(a)).toEqual(R.Ok('*'));
});

it('returns ">1.0.0" when added', () => {
expect(getLowestVersion(b)).toEqual(R.Ok('>1.0.0'));
});

it('returns ">=1.0.0" when added', () => {
expect(getLowestVersion(c)).toEqual(R.Ok('>=1.0.0'));
});

it('returns "^1.0.0" when added', () => {
expect(getLowestVersion(d)).toEqual(R.Ok('^1.0.0'));
});

it('returns "1.x.x" when added', () => {
expect(getLowestVersion(e)).toEqual(R.Ok('1.x.x'));
});

it('returns "~1.0.0" when added', () => {
expect(getLowestVersion(f)).toEqual(R.Ok('~1.0.0'));
});

it('returns "1.0.0" when added', () => {
expect(getLowestVersion(g)).toEqual(R.Ok('1.0.0'));
});

it('returns "1" when added', () => {
expect(getLowestVersion(h)).toEqual(R.Ok(eitherFormat));
});

it('returns "<=1.0.0" when added', () => {
expect(getLowestVersion(i)).toEqual(R.Ok('<=1.0.0'));
});

it('returns "<1.0.0" when added', () => {
expect(getLowestVersion(j)).toEqual(R.Ok('<1.0.0'));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { R } from '@mobily/ts-belt';
import { BaseError } from '../../../../lib/error';
import { clean } from './lib/clean';
import { compareLt } from './lib/compare-semver';
import { getRangeScore } from './lib/get-range-score';

interface LowestVersion {
withRange: string;
semver: string;
}

export function getLowestVersion(
versions: string[],
): R.Result<string, BaseError> {
let lowest: LowestVersion | undefined;

for (const withRange of versions) {
switch (compareLt(withRange, lowest?.semver)) {
// lowest possible, quit early
case '*': {
if (!lowest) lowest = { withRange: '*', semver: '*' };
continue;
}
// impossible to know how the user wants to resolve unsupported versions
case 'invalid': {
return R.Error(new BaseError(`"${withRange}" is not supported`));
}
// we found a new lowest version
case 'lt': {
lowest = newLowestVersion(withRange);
continue;
}
// versions are the same, but one range might be greedier than another
case 'eq': {
const score = getRangeScore(withRange);
const lowestScore = getRangeScore(`${lowest?.withRange}`);
if (score < lowestScore) lowest = newLowestVersion(withRange);
}
}
}

return lowest && lowest.withRange
? R.Ok(lowest.withRange)
: R.Error(new BaseError(`getLowestVersion(): did not return a version`));
}

function newLowestVersion(withRange: string): LowestVersion {
return { withRange, semver: clean(withRange) };
}
14 changes: 13 additions & 1 deletion src/get-context/get-groups/version-group/instance-group/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ import type { VersionGroup } from '..';
import { BaseError } from '../../../../lib/error';
import { isSemver } from '../../../../lib/is-semver';
import { printStrings } from '../../../../lib/print-strings';
import type { Syncpack } from '../../../../types';
import { props } from '../../../get-package-json-files/get-patterns/props';
import type { Instance } from '../../../get-package-json-files/package-json-file/instance';
import { getHighestVersion } from './get-highest-version';
import { getLowestVersion } from './get-lowest-version';

type Standard = Syncpack.Config.VersionGroup.Standard;

export const DELETE = Symbol('DELETE');
export type Delete = typeof DELETE;
Expand Down Expand Up @@ -70,14 +74,22 @@ export class InstanceGroup {
),
);
}
return this.getHighestVersion();
return (versionGroup.groupConfig as Standard).preferVersion ===
'lowestSemver'
? this.getLowestVersion()
: this.getHighestVersion();
}

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

/** If all versions are valid semver, return the lowest one */
getLowestVersion(): R.Result<string, BaseError> {
return getLowestVersion(this.getUniqueVersions());
}

/** Get the first version matched by the `snapTo` packages */
getSnappedVersion(): R.Result<string, BaseError> {
return pipe(
Expand Down
Loading

0 comments on commit a17e423

Please sign in to comment.