Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow multiple versions for external mismatch rule #131

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/five-crews-allow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@manypkg/cli": minor
---

Add an option to allow multiple versions for external mismatch check
17 changes: 12 additions & 5 deletions packages/cli/src/checks/EXTERNAL_MISMATCH.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
makeCheck,
getMostCommonRangeMap,
getClosestAllowedRange,
NORMAL_DEPENDENCY_TYPES
} from "./utils";
import { Package } from "@manypkg/get-packages";
Expand All @@ -11,11 +12,11 @@ type ErrorType = {
workspace: Package;
dependencyName: string;
dependencyRange: string;
mostCommonDependencyRange: string;
expectedRange: string;
};

export default makeCheck<ErrorType>({
validate: (workspace, allWorkspace) => {
validate: (workspace, allWorkspace, rootWorkspace, options) => {
let errors: ErrorType[] = [];
let mostCommonRangeMap = getMostCommonRangeMap(allWorkspace);
for (let depType of NORMAL_DEPENDENCY_TYPES) {
Expand All @@ -25,17 +26,23 @@ export default makeCheck<ErrorType>({
for (let depName in deps) {
let range = deps[depName];
let mostCommonRange = mostCommonRangeMap.get(depName);
const allowedVersions =
options.allowedDependencyVersions &&
options.allowedDependencyVersions[depName];
if (
mostCommonRange !== undefined &&
mostCommonRange !== range &&
!(allowedVersions && allowedVersions.includes(range)) &&
validRange(range)
) {
errors.push({
type: "EXTERNAL_MISMATCH",
workspace,
dependencyName: depName,
dependencyRange: range,
mostCommonDependencyRange: mostCommonRange
expectedRange: allowedVersions
? getClosestAllowedRange(range, allowedVersions)
: mostCommonRange
});
}
}
Expand All @@ -47,12 +54,12 @@ export default makeCheck<ErrorType>({
for (let depType of NORMAL_DEPENDENCY_TYPES) {
let deps = error.workspace.packageJson[depType];
if (deps && deps[error.dependencyName]) {
deps[error.dependencyName] = error.mostCommonDependencyRange;
deps[error.dependencyName] = error.expectedRange;
}
}
return { requiresInstall: true };
},
print: error =>
`${error.workspace.packageJson.name} has a dependency on ${error.dependencyName}@${error.dependencyRange} but the most common range in the repo is ${error.mostCommonDependencyRange}, the range should be set to ${error.mostCommonDependencyRange}`,
`${error.workspace.packageJson.name} has a dependency on ${error.dependencyName}@${error.dependencyRange} but the range should be set to ${error.expectedRange}`,
type: "all"
});
154 changes: 146 additions & 8 deletions packages/cli/src/checks/__tests__/EXTERNAL_MISMATCH.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ it("should error if the ranges are valid and they are not equal", () => {
Object {
"dependencyName": "something",
"dependencyRange": "1.0.0",
"mostCommonDependencyRange": "2.0.0",
"expectedRange": "2.0.0",
"type": "EXTERNAL_MISMATCH",
"workspace": Object {
"dir": "some/fake/dir/pkg-1",
Expand All @@ -41,7 +41,7 @@ it("should error if the ranges are valid and they are not equal", () => {
`);
});

it("should error and return the correct mostCommonDependencyRange when the ranges are valid, they are not equal and there are more than 2", () => {
it("should error and return the correct expectedRange when the ranges are valid, they are not equal and there are more than 2", () => {
let ws = getWS();

ws.get("pkg-1")!.packageJson.dependencies = { something: "1.0.0" };
Expand Down Expand Up @@ -73,7 +73,7 @@ it("should error and return the correct mostCommonDependencyRange when the range
Object {
"dependencyName": "something",
"dependencyRange": "2.0.0",
"mostCommonDependencyRange": "1.0.0",
"expectedRange": "1.0.0",
"type": "EXTERNAL_MISMATCH",
"workspace": Object {
"dir": "some/fake/dir/pkg-2",
Expand All @@ -90,7 +90,7 @@ it("should error and return the correct mostCommonDependencyRange when the range
`);
});

it("should error and return the correct mostCommonDependencyRange when the ranges are valid, but the 2nd dependnecy is most common", () => {
it("should error and return the correct expectedRange when the ranges are valid, but the 2nd dependnecy is most common", () => {
let ws = getWS();

ws.get("pkg-1")!.packageJson.dependencies = { something: "2.0.0" };
Expand Down Expand Up @@ -121,7 +121,7 @@ it("should error and return the correct mostCommonDependencyRange when the range
Object {
"dependencyName": "something",
"dependencyRange": "2.0.0",
"mostCommonDependencyRange": "1.0.0",
"expectedRange": "1.0.0",
"type": "EXTERNAL_MISMATCH",
"workspace": Object {
"dir": "some/fake/dir/pkg-1",
Expand All @@ -141,7 +141,7 @@ it("should error and return the correct mostCommonDependencyRange when the range
expect(errors.length).toEqual(0);
});

it("should error and return the correct mostCommonDependencyRange when the ranges are valid, but everything wants a different version", () => {
it("should error and return the correct expectedRange when the ranges are valid, but everything wants a different version", () => {
let ws = getWS();

ws.get("pkg-1")!.packageJson.dependencies = { something: "1.0.0" };
Expand Down Expand Up @@ -170,7 +170,7 @@ it("should error and return the correct mostCommonDependencyRange when the range
Object {
"dependencyName": "something",
"dependencyRange": "1.0.0",
"mostCommonDependencyRange": "3.0.0",
"expectedRange": "3.0.0",
"type": "EXTERNAL_MISMATCH",
"workspace": Object {
"dir": "some/fake/dir/pkg-1",
Expand All @@ -193,7 +193,7 @@ it("should error and return the correct mostCommonDependencyRange when the range
Object {
"dependencyName": "something",
"dependencyRange": "2.0.0",
"mostCommonDependencyRange": "3.0.0",
"expectedRange": "3.0.0",
"type": "EXTERNAL_MISMATCH",
"workspace": Object {
"dir": "some/fake/dir/pkg-2",
Expand Down Expand Up @@ -230,3 +230,141 @@ it("should not error if the value is not a valid semver range", () => {
errors = internalMismatch.validate(ws.get("pkg-1")!, ws, rootWorkspace, {});
expect(errors.length).toEqual(0);
});

it("should not error if the range is included in the allowedDependencyVersions option", () => {
let ws = getWS();

ws.get("pkg-1")!.packageJson.dependencies = { something: "1.0.0" };

let pkg2 = getFakeWS("pkg-2");
pkg2.packageJson.dependencies = {
something: "2.0.0"
};
ws.set("pkg-2", pkg2);

const options = {
allowedDependencyVersions: {
something: ["1.0.0", "2.0.0"]
}
};

let errors = internalMismatch.validate(pkg2, ws, rootWorkspace, options);
expect(errors.length).toEqual(0);

errors = internalMismatch.validate(
ws.get("pkg-1")!,
ws,
rootWorkspace,
options
);
expect(errors.length).toEqual(0);
});

it("should error and fix the version to the closest allowed one when adding an allowed major", () => {
const options = {
allowedDependencyVersions: {
something: ["^1.0.0", "2.0.0"]
}
};

let ws = getWS();

ws.get("pkg-1")!.packageJson.dependencies = { something: "^1.0.0" };

// version 1.0.0 is the most commonly used one
let pkg1a = getFakeWS("pkg-1a");
pkg1a.packageJson.dependencies = {
something: "^1.0.0"
};
ws.set("pkg-1a", pkg1a);

// version 2.0.0 is allowed
let pkg2 = getFakeWS("pkg-2");
pkg2.packageJson.dependencies = {
something: "2.0.0"
};
ws.set("pkg-2", pkg2);

// try to add version 2.1.0
let pkg2a = getFakeWS("pkg-2a");
pkg2a.packageJson.dependencies = {
something: "^2.1.0"
};
ws.set("pkg-2a", pkg2a);

let errors = internalMismatch.validate(pkg2a, ws, rootWorkspace, options);
expect(errors.length).toEqual(1);
expect(errors[0]).toEqual(
expect.objectContaining({
dependencyName: "something",
dependencyRange: "^2.1.0",
expectedRange: '2.0.0'
})
);
internalMismatch.fix(errors[0], options);
expect(pkg2a.packageJson.dependencies.something).toEqual("2.0.0");

// try to add version 1.0.1
let pkg1b = getFakeWS("pkg-1b");
pkg1b.packageJson.dependencies = {
something: "^1.0.1"
};
ws.set("pkg-1b", pkg1b);

errors = internalMismatch.validate(pkg1b, ws, rootWorkspace, options);
expect(errors.length).toEqual(1);
expect(errors[0]).toEqual(
expect.objectContaining({
dependencyName: "something",
dependencyRange: "^1.0.1",
expectedRange: '^1.0.0'
})
);
internalMismatch.fix(errors[0], options);
expect(pkg1b.packageJson.dependencies.something).toEqual("^1.0.0");
});

it("should error and fix the version to the highest allowed one when adding a newer major", () => {
const options = {
allowedDependencyVersions: {
something: ["1.0.0", "^2.0.0"]
}
};

let ws = getWS();

ws.get("pkg-1")!.packageJson.dependencies = { something: "1.0.0" };

// version 1.0.0 is the most commonly used one
let pkg1a = getFakeWS("pkg-1a");
pkg1a.packageJson.dependencies = {
something: "1.0.0"
};
ws.set("pkg-1a", pkg1a);

// version 2.0.0 is allowed
let pkg2 = getFakeWS("pkg-2");
pkg2.packageJson.dependencies = {
something: "^2.0.0"
};
ws.set("pkg-2", pkg2);

// try to add version 3.0.0
let pkg3 = getFakeWS("pkg-3");
pkg3.packageJson.dependencies = {
something: "3.0.0"
};
ws.set("pkg-3", pkg3);

let errors = internalMismatch.validate(pkg3, ws, rootWorkspace, options);
expect(errors.length).toEqual(1);
expect(errors[0]).toEqual(
expect.objectContaining({
dependencyName: "something",
dependencyRange: "3.0.0",
expectedRange: '^2.0.0'
})
);
internalMismatch.fix(errors[0], options);
expect(pkg3.packageJson.dependencies.something).toEqual("^2.0.0");
});
50 changes: 41 additions & 9 deletions packages/cli/src/checks/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Package } from "@manypkg/get-packages";
import * as semver from "semver";
import { highest } from "sembear";
import * as logger from "../logger";
import { ExitError } from "../errors";

export const NORMAL_DEPENDENCY_TYPES = [
"dependencies",
Expand All @@ -15,7 +17,11 @@ export const DEPENDENCY_TYPES = [
"peerDependencies"
] as const;

export type Options = { defaultBranch?: string; ignoredRules?: string[] };
export type Options = {
defaultBranch?: string;
ignoredRules?: string[];
allowedDependencyVersions?: { [dependency: string]: string[] };
};

type RootCheck<ErrorType> = {
type: "root";
Expand Down Expand Up @@ -120,7 +126,7 @@ function weakMemoize<Arg, Ret>(func: (arg: Arg) => Ret): (arg: Arg) => Ret {
export let getMostCommonRangeMap = weakMemoize(function getMostCommonRanges(
allPackages: Map<string, Package>
) {
let dependencyRangesMapping = new Map<string, {[key: string]: number}>();
let dependencyRangesMapping = new Map<string, { [key: string]: number }>();

for (let [pkgName, pkg] of allPackages) {
for (let depType of NORMAL_DEPENDENCY_TYPES) {
Expand All @@ -143,27 +149,27 @@ export let getMostCommonRangeMap = weakMemoize(function getMostCommonRanges(
}

let mostCommonRangeMap = new Map<string, string>();

for (let [depName, specifierMap] of dependencyRangesMapping) {
const specifierMapEntryArray = Object.entries(specifierMap);

const [first] = specifierMapEntryArray;
const maxValue = specifierMapEntryArray.reduce((acc, value) => {
if(acc[1] === value[1]) {
if (acc[1] === value[1]) {
// If all dependency ranges occurances are equal, pick the highest.
// It's impossible to infer intention of the developer
// It's impossible to infer intention of the developer
// when all ranges occur an equal amount of times
const highestRange = highest([acc[0], value[0]]);
return [ highestRange, acc[1] ];
return [highestRange, acc[1]];
}
if(acc[1] > value[1]) {

if (acc[1] > value[1]) {
return acc;
}
return value;
}, first);

mostCommonRangeMap.set(depName, maxValue[0])
mostCommonRangeMap.set(depName, maxValue[0]);
}
return mostCommonRangeMap;
});
Expand All @@ -183,6 +189,32 @@ export function isArrayEqual(arrA: Array<string>, arrB: Array<string>) {
return true;
}

export function getClosestAllowedRange(
range: string,
allowedVersions: string[]
) {
const major = semver.major(getVersionFromRange(range));
const allowedVersionsWithSameMajor = allowedVersions.filter(
version => semver.major(getVersionFromRange(version)) === major
);
const possibleRanges =
allowedVersionsWithSameMajor.length > 0
? allowedVersionsWithSameMajor
: allowedVersions;
return possibleRanges.sort((a, b) =>
semver.gt(getVersionFromRange(a), getVersionFromRange(b)) ? -1 : 1
)[0];
}

function getVersionFromRange(range: string) {
const minVersion = semver.minVersion(range);
if (minVersion) {
return minVersion;
}
logger.error(`Invalid range: ${range}`);
throw new ExitError(1);
}

function makeCheck<ErrorType>(
check: RootCheckWithFix<ErrorType>
): RootCheckWithFix<ErrorType>;
Expand Down