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

fix(semver): fix prerelease handlings in range utils #4323

Merged
merged 7 commits into from
Feb 16, 2024
Merged
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
80 changes: 40 additions & 40 deletions semver/_comparator_intersects.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
import type { Comparator } from "./types.ts";
import { greaterOrEqual } from "./greater_or_equal.ts";
import { lessOrEqual } from "./less_or_equal.ts";
import { comparatorMin } from "./_comparator_min.ts";
import { comparatorMax } from "./_comparator_max.ts";
import { compare } from "./compare.ts";
import { testRange } from "./test_range.ts";
import { isWildcardComparator } from "./_shared.ts";

/**
* Returns true if the range of possible versions intersects with the other comparators set of possible versions
* @param c0 The left side comparator
Expand All @@ -14,41 +14,41 @@ export function comparatorIntersects(
c0: Comparator,
c1: Comparator,
): boolean {
const l0 = comparatorMin(c0);
const l1 = comparatorMax(c0);
const r0 = comparatorMin(c1);
const r1 = comparatorMax(c1);
const op0 = c0.operator;
const op1 = c1.operator;

if (op0 === "" || op0 === undefined) {
// if c0 is empty comparator, then returns true
if (isWildcardComparator(c0)) {
return true;
}
return testRange(c0, [[c1]]);
} else if (op1 === "" || op1 === undefined) {
if (isWildcardComparator(c1)) {
return true;
}
return testRange(c1, [[c0]]);
}

const cmp = compare(c0, c1);

const sameDirectionIncreasing = (op0 === ">=" || op0 === ">") &&
(op1 === ">=" || op1 === ">");
const sameDirectionDecreasing = (op0 === "<=" || op0 === "<") &&
(op1 === "<=" || op1 === "<");
const sameSemVer = cmp === 0;
const differentDirectionsInclusive = (op0 === ">=" || op0 === "<=") &&
(op1 === ">=" || op1 === "<=");
const oppositeDirectionsLessThan = cmp === -1 &&
(op0 === ">=" || op0 === ">") &&
(op1 === "<=" || op1 === "<");
const oppositeDirectionsGreaterThan = cmp === 1 &&
(op0 === "<=" || op0 === "<") &&
(op1 === ">=" || op1 === ">");

// We calculate the min and max ranges of both comparators.
// The minimum min is 0.0.0, the maximum max is ANY.
//
// Comparators with equality operators have the same min and max.
//
// We then check to see if the min's of either range falls within the span of the other range.
//
// A couple of intersection examples:
// ```
// l0 ---- l1
// r0 ---- r1
// ```
// ```
// l0 ---- l1
// r0 ---- r1
// ```
// ```
// l0 ------ l1
// r0--r1
// ```
// ```
// l0 - l1
// r0 - r1
// ```
//
// non-intersection example
// ```
// l0 -- l1
// r0 -- r1
// ```
return (greaterOrEqual(l0, r0) && lessOrEqual(l0, r1)) ||
(greaterOrEqual(r0, l0) && lessOrEqual(r0, l1));
return sameDirectionIncreasing ||
sameDirectionDecreasing ||
(sameSemVer && differentDirectionsInclusive) ||
oppositeDirectionsLessThan ||
oppositeDirectionsGreaterThan;
}
10 changes: 10 additions & 0 deletions semver/_shared.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.

import { type Comparator } from "./types.ts";

export function compareNumber(
a: number,
b: number,
Expand Down Expand Up @@ -188,3 +190,11 @@ export function parseNumber(input: string, errorMessage: string) {
if (!isValidNumber(number)) throw new TypeError(errorMessage);
return number;
}

export function isWildcardComparator(c: Comparator): boolean {
return (
Number.isNaN(c.major) && Number.isNaN(c.minor) && Number.isNaN(c.patch) &&
(c.prerelease === undefined || c.prerelease.length === 0) &&
(c.build === undefined || c.build.length === 0)
);
}
8 changes: 8 additions & 0 deletions semver/range_intersects_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ Deno.test({
],
[">=1.0.0", "<=1.0.0", true],
[">1.0.0 <1.0.0", "<=0.0.0", false],
// Pre-release ranges
["<1.0.0", ">1.0.0-5", true],
[">1.0.0", "<1.0.0-5", false],
[">1.0.0-2", "<=1.0.0-5", true],
[">=1.0.0-2", "<1.0.0-5", true],
["<7.0.0-beta.20", ">7.0.0-beta.0", true],
["<7.0.0-beta.beta", ">7.0.0-beta.alpha", true],
// Wildcards
["*", "0.0.1", true],
["*", ">=1.0.0", true],
["*", ">1.0.0", true],
Expand Down
4 changes: 2 additions & 2 deletions semver/range_min_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ Deno.test({
[">4 || <2", "0.0.0"],
["<=2 || >=4", "0.0.0"],
[">=4 || <=2", "0.0.0"],
["<0.0.0-beta >0.0.0-alpha", INVALID],
[">0.0.0-alpha <0.0.0-beta", INVALID],
["<0.0.0-beta >=0.0.0-alpha", "0.0.0-alpha"],
[">=0.0.0-alpha <0.0.0-beta", "0.0.0-alpha"],

// Greater than or equal
[">=1.1.1 <2 || >=2.2.2 <2", "1.1.1"],
Expand Down
89 changes: 73 additions & 16 deletions semver/test_range.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,76 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
import type { Range, SemVer } from "./types.ts";
import { greaterOrEqual } from "./greater_or_equal.ts";
import { lessOrEqual } from "./less_or_equal.ts";
import { comparatorMin } from "./_comparator_min.ts";
import { comparatorMax } from "./_comparator_max.ts";
import type { Comparator, Range, SemVer } from "./types.ts";
import { compare } from "./compare.ts";
import { isWildcardComparator } from "./_shared.ts";

function testComparator(version: SemVer, comparator: Comparator): boolean {
if (isWildcardComparator(comparator)) {
return true;
}
const cmp = compare(version, comparator.semver ?? comparator);
switch (comparator.operator) {
case "":
case "=":
case "==":
case "===":
case undefined: {
return cmp === 0;
}
case "!=":
case "!==": {
return cmp !== 0;
}

Check warning on line 22 in semver/test_range.ts

View check run for this annotation

Codecov / codecov/patch

semver/test_range.ts#L21-L22

Added lines #L21 - L22 were not covered by tests
case ">": {
return cmp > 0;
}
case "<": {
return cmp < 0;
}
case ">=": {
return cmp >= 0;
}
case "<=": {
return cmp <= 0;
}
}
}

function testComparatorSet(
version: SemVer,
set: Comparator[],
): boolean {
for (const comparator of set) {
if (!testComparator(version, comparator)) {
return false;
}
}
if (version.prerelease && version.prerelease.length > 0) {
// Find the comparator that is allowed to have prereleases
// For example, ^1.2.3-pr.1 desugars to >=1.2.3-pr.1 <2.0.0
// That should allow `1.2.3-pr.2` to pass.
// However, `1.2.4-alpha.notready` should NOT be allowed,
// even though it's within the range set by the comparators.
for (const comparator of set) {
if (isWildcardComparator(comparator)) {
continue;
}
const { prerelease } = comparator.semver ?? comparator;
if (prerelease && prerelease.length > 0) {
const major = comparator.semver?.major ?? comparator.major;
const minor = comparator.semver?.minor ?? comparator.minor;
const patch = comparator.semver?.patch ?? comparator.patch;
if (
version.major === major && version.minor === minor &&
version.patch === patch
) {
return true;
}
}
}
return false;
}
return true;
}

/**
* Test to see if the version satisfies the range.
Expand All @@ -15,15 +82,5 @@
version: SemVer,
range: Range,
): boolean {
for (const r of range) {
if (
r.every((c) =>
greaterOrEqual(version, comparatorMin(c)) &&
lessOrEqual(version, comparatorMax(c))
)
) {
return true;
}
}
return false;
return range.some((set) => testComparatorSet(version, set));
}
34 changes: 12 additions & 22 deletions semver/test_range_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@ import { parse } from "./parse.ts";
import { parseRange } from "./parse_range.ts";
import { testRange } from "./test_range.ts";

Deno.test("range", async (t) => {
Deno.test("testRange() returns true when the version is in the range", async (t) => {
const versions: [string, string][] = [
["1.0.0 - 2.0.0", "1.2.3"],
["^1.2.3+build", "1.2.3"],
["^1.2.3+build", "1.3.0"],
["1.2.3-pre+asdf - 2.4.3-pre+asdf", "1.2.3"],
["1.2.3-pre+asdf - 2.4.3-pre+asdf", "1.2.3-pre.2"],
["1.2.3-pre+asdf - 2.4.3-pre+asdf", "2.4.3-alpha"],
["1.2.3+asdf - 2.4.3+asdf", "2.4.3-alpha"],
["1.2.3+asdf - 2.4.3+asdf", "2.4.3"],
["1.0.0", "1.0.0"],
[">=*", "0.2.4"],
Expand Down Expand Up @@ -71,7 +70,6 @@ Deno.test("range", async (t) => {
["1.2.3 >=1.2.1", "1.2.3"],
[">=1.2.3 >=1.2.1", "1.2.3"],
[">=1.2.1 >=1.2.3", "1.2.3"],
["<=1.2.3", "1.2.3-beta"],
[">=1.2", "1.2.8"],
["^1.2.3", "1.8.1"],
["^0.1.2", "0.1.2"],
Expand Down Expand Up @@ -102,14 +100,15 @@ Deno.test("range", async (t) => {
});

Deno.test({
name: "negativeRange",
name: "testRange() returns false when the version is not in the range",
fn: async (t) => {
const versions: [string, string][] = [
["1.0.0 - 2.0.0", "2.2.3"],
["1.2.3+asdf - 2.4.3+asdf", "1.2.3-pre.2"],
["^1.2.3+build", "2.0.0"],
["^1.2.3+build", "1.2.0"],
["1.2.3-pre+asdf - 2.4.3-pre+asdf", "2.4.3"],
["1.2.3+asdf - 2.4.3+asdf", "2.4.3-alpha"],
["^1.2.3", "1.2.3-pre"],
["^1.2", "1.2.0-pre"],
[">1.2", "1.3.0-beta"],
Expand Down Expand Up @@ -169,6 +168,14 @@ Deno.test({
["^1.2.3", "1.2.2"],
["^1.2", "1.1.9"],

// unsatisfiable patterns with prereleases
["*", "1.0.0-rc1"],
["^1.0.0-0", "1.0.1-rc1"],
["^1.0.0-rc2", "1.0.1-rc1"],
["^1.0.0", "1.0.1-rc1"],
["^1.0.0", "1.1.0-rc1"],
["<=1.2.3", "1.2.3-beta"],

// invalid ranges never satisfied!
["blerg", "1.2.3"],
["^1.2.3", "2.0.0-pre"],
Expand All @@ -185,28 +192,11 @@ Deno.test({
},
});

Deno.test("unlockedPrereleaseRange", function () {
const versions: [string, string][] = [
["*", "1.0.0-rc1"],
["^1.0.0-0", "1.0.1-rc1"],
["^1.0.0-rc2", "1.0.1-rc1"],
["^1.0.0", "1.0.1-rc1"],
["^1.0.0", "1.1.0-rc1"],
];

for (const [r, v] of versions) {
const range = parseRange(r);
const s = parse(v);
const found = testRange(s, range);
assert(found, `${r} not satisfied by ${v}`);
}
});

Deno.test("negativeUnlockedPrereleaseRange", function () {
const versions: [string, string][] = [
["^1.0.0", "1.0.0-rc1"],
["^1.2.3-rc2", "2.0.0"],
["^1.0.0", "2.0.0-rc1"], // todo: review, this is inverted from original test case
["^1.0.0", "2.0.0-rc1"],
];

for (const [r, v] of versions) {
Expand Down