Skip to content
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
47 changes: 40 additions & 7 deletions ranges/min-version.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,42 @@ const SemVer = require('../classes/semver')
const Range = require('../classes/range')
const gt = require('../functions/gt')

const getPrereleaseTuples = (comparators) => {
const tuples = new Set()
for (const { semver } of comparators) {
if (semver.prerelease.length > 0) {
tuples.add(`${semver.major}.${semver.minor}.${semver.patch}`)
}
}
return tuples
}

const advanceStrictBound = (version, prereleaseTuples) => {
if (version.prerelease.length === 0) {
version.patch++
const tuple = `${version.major}.${version.minor}.${version.patch}`
if (prereleaseTuples.has(tuple)) {
version.prerelease = [0]
}
} else {
version.prerelease.push(0)
}
version.raw = version.format()
}

const hasInvalidAdjustedMinimum = (comparators, setMin) => {
if (setMin.prerelease.length === 0) {
return false
}
const adjusted = comparators.some(({ operator, semver }) =>
operator === '>' &&
semver.prerelease.length === 0 &&
semver.major === setMin.major &&
semver.minor === setMin.minor &&
semver.patch + 1 === setMin.patch)
return adjusted && !comparators.every(comparator => comparator.test(setMin))
}

const minVersion = (range, loose) => {
range = new Range(range, loose)

Expand All @@ -20,19 +56,15 @@ const minVersion = (range, loose) => {
minver = null
for (let i = 0; i < range.set.length; ++i) {
const comparators = range.set[i]
const prereleaseTuples = getPrereleaseTuples(comparators)

let setMin = null
comparators.forEach((comparator) => {
// Clone to avoid manipulating the comparator's semver object.
const compver = new SemVer(comparator.semver.version)
switch (comparator.operator) {
case '>':
if (compver.prerelease.length === 0) {
compver.patch++
} else {
compver.prerelease.push(0)
}
compver.raw = compver.format()
advanceStrictBound(compver, prereleaseTuples)
/* fallthrough */
case '':
case '>=':
Expand All @@ -49,7 +81,8 @@ const minVersion = (range, loose) => {
throw new Error(`Unexpected operation: ${comparator.operator}`)
}
})
if (setMin && (!minver || gt(minver, setMin))) {
if (setMin && !hasInvalidAdjustedMinimum(comparators, setMin) &&
(!minver || gt(minver, setMin))) {
minver = setMin
}
}
Expand Down
260 changes: 260 additions & 0 deletions test/ranges/min-version.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const { test } = require('tap')
const minVersion = require('../../ranges/min-version')
const semver = require('../..')

test('minimum version in range tests', (t) => {
// [range, minimum, loose]
Expand Down Expand Up @@ -79,3 +80,262 @@ test('minimum version in range tests', (t) => {
})
t.end()
})

test('minimum explicitly admitted prerelease', (t) => {
const range = '>0.0.0 <=0.0.1-0'
const expected = '0.0.1-0'
t.ok(semver.satisfies(expected, range), 'expected witness satisfies the range')
t.ok(semver.gt(expected, '0.0.0'), 'witness exceeds the lower bound')
t.ok(semver.lte(expected, '0.0.1-0'), 'witness meets the upper bound')
const min = semver.minVersion(range)
t.equal(min && min.version, expected, 'minimum is the admitted prerelease floor')
t.type(min, semver.SemVer)
t.match(min, { version: expected, raw: expected })
t.end()
})

test('prerelease permission follows the comparator set and numeric tuple', (t) => {
[
['<=0.0.1-0 >0.0.0', '0.0.1-0'],
['>1.2.3 <1.2.4-beta', '1.2.4-0'],
['<1.2.4-beta >1.2.3', '1.2.4-0'],
['>1.2.3 <=1.2.4-beta', '1.2.4-0'],
['>1.2.3 <=1.2.4', '1.2.4'],
['>1.2.3 <=1.2.5-alpha', '1.2.4'],
['>1.2.3 <=1.3.4-alpha', '1.2.4'],
['>1.2.3 <=2.2.4-alpha', '1.2.4'],
['>1.2.3 >=1.2.4-alpha <=1.2.4-beta', '1.2.4-alpha'],
['>1.2.3 >1.2.4-alpha <=1.2.4-beta', '1.2.4-alpha.0'],
['>1.2.3 1.2.4-beta', '1.2.4-beta'],
['>0.0.0 <=0.0.1-0 || >=1.0.0', '0.0.1-0'],
['>=1.0.0 || >0.0.0 <=0.0.1-0', '0.0.1-0'],
['>1.2.3 || >=1.2.4-alpha', '1.2.4-alpha'],
['>=1.2.4-alpha || >1.2.3', '1.2.4-alpha'],
].forEach(([range, expected]) => {
t.ok(semver.satisfies(expected, range), `${expected} satisfies ${range}`)
const min = semver.minVersion(range)
t.equal(min && min.version, expected, `minimum of ${range}`)
t.type(min, semver.SemVer)
})
t.end()
})

test('prerelease permission does not weaken other bounds', (t) => {
[
'>1.2.3 <1.2.4-0',
'>1.2.3 <1.2.4',
'>1.2.3 >=1.2.4 <=1.2.4-beta',
].forEach((range) => {
t.equal(semver.minVersion(range), null, `no minimum for ${range}`)
})
t.end()
})

test('prereleases require permission for their own tuple in their own branch', (t) => {
[
'>1.2.3 <=1.2.4',
'>1.2.3 <=1.2.5-alpha',
'>1.2.3 <=1.3.4-alpha',
'>1.2.3 <=2.2.4-alpha',
'>1.2.3 || >=1.2.4-alpha',
'>=1.2.4-alpha || >1.2.3',
].forEach((range) => {
t.notOk(semver.satisfies('1.2.4-0', range), `1.2.4-0 is excluded by ${range}`)
})
t.end()
})

test('explicit prerelease admission respects options', (t) => {
const range = '>0.0.0 <=0.0.1-0'
const expected = '0.0.1-0'
;[false, { includePrerelease: false }, { includePrerelease: true }].forEach((options) => {
t.ok(semver.satisfies(expected, range, options), 'expected witness satisfies with options')
const min = semver.minVersion(range, options)
t.equal(min && min.version, expected)
t.type(min, semver.SemVer)
t.match(min, { version: expected, raw: expected })
})
t.end()
})

test('explicit prerelease admission respects loose parsing', (t) => {
const range = '>01.02.03 <=01.02.04-0'
const expected = '1.2.4-0'
;[true, { loose: true }].forEach((options) => {
t.ok(semver.satisfies(expected, range, options), 'expected loose witness satisfies')
const min = semver.minVersion(range, options)
t.equal(min && min.version, expected)
t.type(min, semver.SemVer)
})
t.throws(() => semver.minVersion(range), TypeError)
t.end()
})

test('minimum version preserves Range comparator state across repeated calls', (t) => {
const range = new semver.Range('>0.0.0 <=0.0.1-0')
const expected = '0.0.1-0'
const before = JSON.stringify(range.set)
const versions = range.set[0].map(comparator => comparator.semver)
const prereleases = versions.map(version => version.prerelease)
t.ok(semver.satisfies(expected, range), 'expected Range witness satisfies')
for (let i = 0; i < 3; i++) {
const min = semver.minVersion(range)
t.equal(min && min.version, expected)
t.type(min, semver.SemVer)
t.match(min, { version: expected, raw: expected })
t.equal(JSON.stringify(range.set), before, 'comparator values stay unchanged')
range.set[0].forEach((comparator, index) => {
t.equal(comparator.semver, versions[index], 'comparator version identity stays unchanged')
t.equal(comparator.semver.prerelease, prereleases[index], 'prerelease array stays unchanged')
})
}
t.end()
})

test('minimum version retains invalid input errors', (t) => {
t.throws(() => semver.minVersion('not a range'), TypeError)
t.throws(() => semver.minVersion(null), TypeError)
t.end()
})

const assertRepairMinimum = (t, range, expected, ...options) => {
t.ok(semver.satisfies(expected, range, ...options), `${expected} satisfies ${range}`)
const min = semver.minVersion(range, ...options)
t.equal(min && min.version, expected, `minimum of ${range}`)
t.type(min, semver.SemVer)
t.match(min, { version: expected, raw: expected })
}

test('invalid adjusted minima cannot displace another branch minimum', (t) => {
[
['>1.2.3 <1.2.4-0', '>=1.2.4', '1.2.4'],
['>1.2.3 <1.2.4-0', '>=1.2.4-alpha', '1.2.4-alpha'],
['>1.2.3 <1.2.4-0', '>1.2.4-alpha', '1.2.4-alpha.0'],
['>0.0.0 <0.0.1-0', '>=0.0.1', '0.0.1'],
['>0.0.0 <0.0.1-0', '>0.0.1-alpha', '0.0.1-alpha.0'],
].forEach(([invalid, alternative, expected]) => {
[[], [false], [{ includePrerelease: false }], [{ includePrerelease: true }]]
.forEach((options) => {
[`${invalid} || ${alternative}`, `${alternative} || ${invalid}`].forEach((range) => {
assertRepairMinimum(t, range, expected, ...options)
})
})
})
t.end()
})

test('adjusted branch admission respects loose parsing', (t) => {
[
['>=01.02.04', '1.2.4'],
['>01.02.04-alpha', '1.2.4-alpha.0'],
].forEach(([alternative, expected]) => {
const invalid = '>01.02.03 <01.02.04-0'
;[`${invalid} || ${alternative}`, `${alternative} || ${invalid}`].forEach((range) => {
[true, { loose: true }].forEach((options) => {
assertRepairMinimum(t, range, expected, options)
})
t.throws(() => semver.minVersion(range), TypeError)
})
})
t.end()
})

test('branch admission validates the completed adjusted minimum', (t) => {
[
['<1.2.4-0 >1.2.3', '>=1.2.4', '1.2.4'],
['<1.2.4-0 >1.2.3', '>1.2.4-alpha', '1.2.4-alpha.0'],
['>1.2.3 >1.2.4-alpha <=1.2.4-alpha', '>=1.2.4', '1.2.4'],
['>1.2.3 >=1.2.4-beta <1.2.4-beta', '>=1.2.4', '1.2.4'],
['>1.2.3 1.2.4-beta <1.2.4-beta', '>=1.2.4', '1.2.4'],
['>1.2.3 <=1.2.4-0', '>=1.2.4', '1.2.4-0'],
['>1.2.3 <1.2.4-0', '>1.2.3 >=1.2.4-alpha <=1.2.4-beta', '1.2.4-alpha'],
['>1.2.3 <1.2.4-0', '>=2.0.0 || >=1.2.4', '1.2.4'],
].forEach(([left, right, expected]) => {
[[], [{ includePrerelease: true }]].forEach((options) => {
[`${left} || ${right}`, `${right} || ${left}`].forEach((range) => {
assertRepairMinimum(t, range, expected, ...options)
})
})
})
assertRepairMinimum(t, '>=1.2.4 || >=2.0.0 || >1.2.3 <1.2.4-0', '1.2.4')
t.end()
})

test('impossible adjusted branches still have no minimum', (t) => {
[
['>1.2.3 <1.2.4-0', '1.2.4-0'],
['<1.2.4-0 >1.2.3', '1.2.4-0'],
['>0.0.0 <0.0.1-0', '0.0.1-0'],
['>1.2.3 >1.2.4-alpha <=1.2.4-alpha', '1.2.4-alpha.0'],
['>1.2.3 >=1.2.4-beta <1.2.4-beta', '1.2.4-beta'],
['>1.2.3 1.2.4-beta <1.2.4-beta', '1.2.4-beta'],
['>1.2.3 <1.2.4-0 || >0.0.0 <0.0.1-0', '1.2.4-0'],
['>0.0.0 <0.0.1-0 || >1.2.3 <1.2.4-0', '0.0.1-0'],
].forEach(([range, excluded]) => {
[[], [{ includePrerelease: true }]].forEach((options) => {
t.notOk(semver.satisfies(excluded, range, ...options), `${excluded} is excluded by ${range}`)
t.equal(semver.minVersion(range, ...options), null, `no minimum for ${range}`)
})
})
t.end()
})

test('unadjusted final candidates retain union selection behavior', (t) => {
[
'>=1.2.4-0 <1.2.4-0',
'>1.2.2 >=1.2.3-0 >=1.2.4-0 <1.2.4-0',
'>1.1.3 >=1.1.4-0 >=1.2.4-0 <1.2.4-0',
'>0.2.3 >=0.2.4-0 >=1.2.4-0 <1.2.4-0',
].forEach((left) => {
[`${left} || >1.2.3`, `>1.2.3 || ${left}`].forEach((range) => {
assertRepairMinimum(t, range, '1.2.4-0', { includePrerelease: true })
})
})
t.end()
})

test('adjusted branch admission preserves wildcard and early zero minima', (t) => {
[
['*', '0.0.0'],
['>1.2.3 <1.2.4-0 || *', '0.0.0'],
['* || >1.2.3 <1.2.4-0', '0.0.0'],
['<0.0.0-beta', '0.0.0-0'],
].forEach(([range, expected]) => {
assertRepairMinimum(t, range, expected)
})
t.end()
})

const assertBranchState = (t, range, before, versions, prereleases) => {
t.equal(JSON.stringify(range.set), before, 'all comparator values stay unchanged')
range.set.forEach((comparators, branch) => {
comparators.forEach((comparator, index) => {
t.equal(comparator.semver, versions[branch][index], 'all version identities stay unchanged')
t.equal(comparator.semver.prerelease, prereleases[branch][index],
'all prerelease array identities stay unchanged')
})
})
}

test('adjusted OR minima preserve every Range branch across repeated calls', (t) => {
[
['>1.2.3 <1.2.4-0', '>=1.2.4', '1.2.4', []],
['>1.2.3 <1.2.4-0', '>1.2.4-alpha', '1.2.4-alpha.0', []],
['>1.2.3 <1.2.4-0', '>=1.2.4', '1.2.4', [{ includePrerelease: true }]],
['>1.2.3 <1.2.4-0', '>1.2.4-alpha', '1.2.4-alpha.0', [{ includePrerelease: true }]],
['>=1.2.4-0 <1.2.4-0', '>1.2.3', '1.2.4-0', [{ includePrerelease: true }]],
].forEach(([left, right, expected, options]) => {
[`${left} || ${right}`, `${right} || ${left}`].forEach((input) => {
const range = new semver.Range(input, ...options)
const before = JSON.stringify(range.set)
const versions = range.set.map(comparators =>
comparators.map(comparator => comparator.semver))
const prereleases = versions.map(branch => branch.map(version => version.prerelease))
for (let i = 0; i < 3; i++) {
assertRepairMinimum(t, range, expected, ...options)
assertBranchState(t, range, before, versions, prereleases)
}
})
})
t.end()
})