[2.x] fix: Skip eviction warning when winner satisfies version range (#8616)

- Add `versionSatisfiesRange()` function to `VersionRange.scala` supporting Maven-style ranges (`[x,y)`, `(x,y]`, `[x,)`, etc.) and plus ranges (`1.0+`)
- Check if winner version satisfies evicted module's version range in `guessCompatible()`
This commit is contained in:
calm 2026-01-22 12:52:58 -08:00 committed by GitHub
parent 398af2eaaa
commit 8a518dfb98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 245 additions and 22 deletions

View File

@ -2,6 +2,8 @@ package sbt
package internal
package librarymanagement
import sbt.librarymanagement.VersionNumber
object VersionRange {
/** True if the revision is an ivy-range, not a complete revision. */
@ -13,6 +15,133 @@ object VersionRange {
(revision.contains(")"))
}
/**
* Checks if a version satisfies a version range.
* @param version The version to check (e.g., "4.2.1")
* @param range The version range (e.g., "[4.1.0,5)" or "[1.0,2.0]")
* @return true if version is within the range, false otherwise
*/
def versionSatisfiesRange(version: String, range: String): Boolean = {
if (!isVersionRange(range)) {
// Not a range, just compare directly
version == range
} else if (range.endsWith("+")) {
// Handle plus ranges like "1.0+" meaning >= 1.0
val base = range.dropRight(1)
compareVersions(version, base) >= 0
} else if (hasMavenVersionRange(range)) {
// Parse Maven-style range like [1.0,2.0) or (1.0,2.0]
parseMavenRange(range) match {
case Some((lowerBound, lowerInclusive, upperBound, upperInclusive)) =>
val lowerOk = lowerBound match {
case Some(lb) =>
val cmp = compareVersions(version, lb)
if (lowerInclusive) cmp >= 0 else cmp > 0
case None => true
}
val upperOk = upperBound match {
case Some(ub) =>
val cmp = compareVersions(version, ub)
if (upperInclusive) cmp <= 0 else cmp < 0
case None => true
}
lowerOk && upperOk
case None => false
}
} else {
false
}
}
/**
* Parses a Maven-style version range.
* @return Some((lowerBound, lowerInclusive, upperBound, upperInclusive)) or None
*/
private def parseMavenRange(
range: String
): Option[(Option[String], Boolean, Option[String], Boolean)] = {
val trimmed = range.trim
if (trimmed.length < 2) None
else {
val startChar = trimmed.head
val endChar = trimmed.last
val lowerInclusive = startChar == '['
val upperInclusive = endChar == ']'
if (!Set('[', '(').contains(startChar) || !Set(']', ')').contains(endChar)) {
None
} else {
val inner = trimmed.substring(1, trimmed.length - 1)
val commaIdx = inner.indexOf(',')
if (commaIdx < 0) {
// Single version constraint like [1.0] means exactly 1.0
val v = inner.trim
if (v.nonEmpty) Some((Some(v), true, Some(v), true))
else None
} else {
val lower = inner.substring(0, commaIdx).trim
val upper = inner.substring(commaIdx + 1).trim
Some(
(
if (lower.nonEmpty) Some(lower) else None,
lowerInclusive,
if (upper.nonEmpty) Some(upper) else None,
upperInclusive
)
)
}
}
}
}
/**
* Compares two version strings.
* @return negative if v1 < v2, 0 if v1 == v2, positive if v1 > v2
*/
private def compareVersions(v1: String, v2: String): Int = {
val vn1 = VersionNumber(v1)
val vn2 = VersionNumber(v2)
// Compare numeric parts first
val nums1 = vn1.numbers
val nums2 = vn2.numbers
val maxLen = math.max(nums1.length, nums2.length)
val numericComparison = (0 until maxLen).iterator
.map { i =>
val n1 = if (i < nums1.length) nums1(i) else 0L
val n2 = if (i < nums2.length) nums2(i) else 0L
n1.compare(n2)
}
.find(_ != 0)
numericComparison match {
case Some(cmp) => cmp
case None =>
// If numeric parts are equal, compare tags (versions with tags are usually pre-releases)
val tags1 = vn1.tags
val tags2 = vn2.tags
// No tags means release version, which is higher than any pre-release
if (tags1.isEmpty && tags2.nonEmpty) 1
else if (tags1.nonEmpty && tags2.isEmpty) -1
else {
// Compare tags lexicographically
val tagMaxLen = math.max(tags1.length, tags2.length)
val tagComparison = (0 until tagMaxLen).iterator
.map { i =>
val t1 = if (i < tags1.length) tags1(i) else ""
val t2 = if (i < tags2.length) tags2(i) else ""
t1.compare(t2)
}
.find(_ != 0)
tagComparison.getOrElse(0)
}
}
}
// Assuming Ivy is used to resolve conflict, this removes the version range
// when it is open-ended to avoid dependency resolution hitting the Internet to get the latest.
// See https://github.com/sbt/sbt/issues/2954

View File

@ -3,7 +3,7 @@ package sbt.librarymanagement
import collection.mutable
import Configurations.Compile
import ScalaArtifacts.{ LibraryID, CompilerID }
import sbt.internal.librarymanagement.VersionSchemes
import sbt.internal.librarymanagement.{ VersionSchemes, VersionRange }
import sbt.util.Logger
import sbt.util.ShowLines
@ -337,28 +337,40 @@ object EvictionWarning {
def guessCompatible(p: EvictionPair): Boolean =
p.evicteds forall { r =>
val winnerOpt = p.winner map { _.module }
val extraAttributes = ((p.winner match {
case Some(r) => r.extraAttributes
case _ => Map.empty
}): collection.immutable.Map[String, String]) ++ (winnerOpt match {
case Some(w) => w.extraAttributes
case _ => Map.empty
})
val schemeOpt = VersionSchemes.extractFromExtraAttributes(extraAttributes)
val f = (winnerOpt, schemeOpt) match {
case (Some(_), Some(VersionSchemes.Always)) =>
EvictionWarningOptions.guessTrue
case (Some(_), Some(VersionSchemes.Strict)) =>
EvictionWarningOptions.guessStrict
case (Some(_), Some(VersionSchemes.EarlySemVer)) =>
EvictionWarningOptions.guessEarlySemVer
case (Some(_), Some(VersionSchemes.SemVerSpec)) =>
EvictionWarningOptions.guessSemVer
case (Some(_), Some(VersionSchemes.PackVer)) =>
EvictionWarningOptions.evalPvp
case _ => options.guessCompatible(_)
// Check if the evicted module's revision is a version range and if the winner satisfies it
// This handles cases like [4.1.0,5) where 4.2.1 would be within range (fixes #3978)
val evictedRev = r.module.revision
val winnerSatisfiesRange: Boolean = winnerOpt match {
case Some(winner) if VersionRange.isVersionRange(evictedRev) =>
VersionRange.versionSatisfiesRange(winner.revision, evictedRev)
case _ => false
}
if (winnerSatisfiesRange) {
true
} else {
val extraAttributes = ((p.winner match {
case Some(r) => r.extraAttributes
case _ => Map.empty
}): collection.immutable.Map[String, String]) ++ (winnerOpt match {
case Some(w) => w.extraAttributes
case _ => Map.empty
})
val schemeOpt = VersionSchemes.extractFromExtraAttributes(extraAttributes)
val f = (winnerOpt, schemeOpt) match {
case (Some(_), Some(VersionSchemes.Always)) =>
EvictionWarningOptions.guessTrue
case (Some(_), Some(VersionSchemes.Strict)) =>
EvictionWarningOptions.guessStrict
case (Some(_), Some(VersionSchemes.EarlySemVer)) =>
EvictionWarningOptions.guessEarlySemVer
case (Some(_), Some(VersionSchemes.SemVerSpec)) =>
EvictionWarningOptions.guessSemVer
case (Some(_), Some(VersionSchemes.PackVer)) =>
EvictionWarningOptions.evalPvp
case _ => options.guessCompatible(_)
}
f((r.module, winnerOpt, module.scalaModuleInfo))
}
f((r.module, winnerOpt, module.scalaModuleInfo))
}
pairs foreach {
case p if isScalaArtifact(module, p.organization, p.name) =>

View File

@ -16,4 +16,86 @@ class VersionRangeSpec extends UnitSpec {
def stripTo(s: String, expected: Option[String]) =
assert(VersionRange.stripMavenVersionRange(s) == expected)
// Tests for versionSatisfiesRange (issue #3978)
"versionSatisfiesRange" should "return true when version is within inclusive range [4.1.0,5)" in {
assert(VersionRange.versionSatisfiesRange("4.2.1", "[4.1.0,5)") == true)
}
it should "return true for version at lower bound of inclusive range" in {
assert(VersionRange.versionSatisfiesRange("4.1.0", "[4.1.0,5)") == true)
}
it should "return false for version at upper bound of exclusive range" in {
assert(VersionRange.versionSatisfiesRange("5", "[4.1.0,5)") == false)
assert(VersionRange.versionSatisfiesRange("5.0", "[4.1.0,5)") == false)
assert(VersionRange.versionSatisfiesRange("5.0.0", "[4.1.0,5)") == false)
}
it should "return false for version below range" in {
assert(VersionRange.versionSatisfiesRange("4.0.9", "[4.1.0,5)") == false)
assert(VersionRange.versionSatisfiesRange("3.0.0", "[4.1.0,5)") == false)
}
it should "return false for version above range" in {
assert(VersionRange.versionSatisfiesRange("5.0.1", "[4.1.0,5)") == false)
assert(VersionRange.versionSatisfiesRange("6.0.0", "[4.1.0,5)") == false)
}
it should "handle fully inclusive range [1.0,2.0]" in {
assert(VersionRange.versionSatisfiesRange("1.0", "[1.0,2.0]") == true)
assert(VersionRange.versionSatisfiesRange("1.5", "[1.0,2.0]") == true)
assert(VersionRange.versionSatisfiesRange("2.0", "[1.0,2.0]") == true)
assert(VersionRange.versionSatisfiesRange("2.0.1", "[1.0,2.0]") == false)
assert(VersionRange.versionSatisfiesRange("0.9", "[1.0,2.0]") == false)
}
it should "handle fully exclusive range (1.0,2.0)" in {
assert(VersionRange.versionSatisfiesRange("1.0", "(1.0,2.0)") == false)
assert(VersionRange.versionSatisfiesRange("1.0.1", "(1.0,2.0)") == true)
assert(VersionRange.versionSatisfiesRange("1.5", "(1.0,2.0)") == true)
assert(VersionRange.versionSatisfiesRange("1.9.9", "(1.0,2.0)") == true)
assert(VersionRange.versionSatisfiesRange("2.0", "(1.0,2.0)") == false)
}
it should "handle open upper bound [1.0,)" in {
assert(VersionRange.versionSatisfiesRange("1.0", "[1.0,)") == true)
assert(VersionRange.versionSatisfiesRange("1.5", "[1.0,)") == true)
assert(VersionRange.versionSatisfiesRange("100.0", "[1.0,)") == true)
assert(VersionRange.versionSatisfiesRange("0.9", "[1.0,)") == false)
}
// Exact reproduction case from issue #3978 comment by eed3si9n
it should "handle angular-bootstrap reproduction case [1.3.0,)" in {
assert(VersionRange.versionSatisfiesRange("1.4.7", "[1.3.0,)") == true)
assert(VersionRange.versionSatisfiesRange("1.3.0", "[1.3.0,)") == true)
assert(VersionRange.versionSatisfiesRange("1.2.9", "[1.3.0,)") == false)
}
it should "handle open lower bound (,2.0]" in {
assert(VersionRange.versionSatisfiesRange("0.1", "(,2.0]") == true)
assert(VersionRange.versionSatisfiesRange("1.0", "(,2.0]") == true)
assert(VersionRange.versionSatisfiesRange("2.0", "(,2.0]") == true)
assert(VersionRange.versionSatisfiesRange("2.0.1", "(,2.0]") == false)
}
it should "handle plus ranges like 1.0+" in {
assert(VersionRange.versionSatisfiesRange("1.0", "1.0+") == true)
assert(VersionRange.versionSatisfiesRange("1.1", "1.0+") == true)
assert(VersionRange.versionSatisfiesRange("2.0", "1.0+") == true)
assert(VersionRange.versionSatisfiesRange("0.9", "1.0+") == false)
}
it should "handle exact version (not a range)" in {
assert(VersionRange.versionSatisfiesRange("1.0", "1.0") == true)
assert(VersionRange.versionSatisfiesRange("1.0.0", "1.0") == false)
assert(VersionRange.versionSatisfiesRange("1.1", "1.0") == false)
}
it should "handle single version constraint [1.0]" in {
assert(VersionRange.versionSatisfiesRange("1.0", "[1.0]") == true)
// Note: 1.0.0 is considered equal to 1.0 in semantic version comparison
assert(VersionRange.versionSatisfiesRange("1.0.0", "[1.0]") == true)
assert(VersionRange.versionSatisfiesRange("1.1", "[1.0]") == false)
}
}