From f015b788e17168e7d794cc90a26b4b48a48cddcb Mon Sep 17 00:00:00 2001 From: Alex Archambault Date: Fri, 4 Sep 2026 12:11:52 +0200 Subject: [PATCH 1/3] Make version ordering consistent with equality Add a semantic comparison for interval and constraint logic while using representation as a tie-breaker for total ordering. Co-authored-by: Claude Opus 5 --- README.md | 16 +++- README.template.md | 16 +++- .../shared/src/coursier/version/Version.scala | 35 ++++++- .../version/VersionCompatibility.scala | 6 +- .../coursier/version/VersionConstraint.scala | 6 +- .../coursier/version/VersionInterval.scala | 10 +- .../src/coursier/version/VersionTests.scala | 93 +++++++++++++++++++ 7 files changed, 167 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 4d16323..cce1010 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,21 @@ The coursier documentation [details](https://get-coursier.io/docs/other-version- how versions are compared. `Version` implements `Ordered[Version]`, so that `Version` instances can be compared together, -and a sequence of `Version`s can be sorted. +and a sequence of `Version`s can be sorted. That order is total, and consistent with `equals`: +`a.compare(b)` is `0` if and only if `a == b`. Versions that mean the same thing but are spelled +differently, like `1.0` and `1.0.0`, or `1.2+foo` and `1.2+bar`, are ordered by their +representation, so that sorted collections and hash-based ones agree on which versions are +distinct. + +Use `compareSemantic` to compare versions up to that equivalence - it returns `0` for versions +that only differ by padding, separators, or build metadata. This is the order that decides +whether a version sits in an interval, or whether two constraints can be reconciled: +```scala +Version("1.0").compare(Version("1.0.0")) +// res: Int = -2 +Version("1.0").compareSemantic(Version("1.0.0")) +// res: Int = 0 +``` ## `VersionConstraint` diff --git a/README.template.md b/README.template.md index 19fbefe..39acb6f 100644 --- a/README.template.md +++ b/README.template.md @@ -54,7 +54,21 @@ The coursier documentation [details](https://get-coursier.io/docs/other-version- how versions are compared. `Version` implements `Ordered[Version]`, so that `Version` instances can be compared together, -and a sequence of `Version`s can be sorted. +and a sequence of `Version`s can be sorted. That order is total, and consistent with `equals`: +`a.compare(b)` is `0` if and only if `a == b`. Versions that mean the same thing but are spelled +differently, like `1.0` and `1.0.0`, or `1.2+foo` and `1.2+bar`, are ordered by their +representation, so that sorted collections and hash-based ones agree on which versions are +distinct. + +Use `compareSemantic` to compare versions up to that equivalence - it returns `0` for versions +that only differ by padding, separators, or build metadata. This is the order that decides +whether a version sits in an interval, or whether two constraints can be reconciled: +```scala +Version("1.0").compare(Version("1.0.0")) +// res: Int = -2 +Version("1.0").compareSemantic(Version("1.0.0")) +// res: Int = 0 +``` ## `VersionConstraint` diff --git a/versions/shared/src/coursier/version/Version.scala b/versions/shared/src/coursier/version/Version.scala index 4ae1845..f18f1fd 100644 --- a/versions/shared/src/coursier/version/Version.scala +++ b/versions/shared/src/coursier/version/Version.scala @@ -18,10 +18,41 @@ case class Version(repr: String) extends Ordered[Version] { items0 = Version.items(repr) items0 } - def compare(other: Version) = { + /** + * Total order on versions, consistent with `equals` and `hashCode`. + * + * `a.compare(b) == 0` if and only if `a == b`. Versions that are semantically + * equivalent but spelled differently (`1.0` and `1.0.0`, `1.2` and `1.2+foo`, + * `1.2+bar` and `1.2+foo`, …) are ordered by their representation, so that + * sorted collections and hash-based ones agree on which versions are distinct. + * + * Use [[compareSemantic]] to compare versions up to that equivalence. + */ + def compare(other: Version): Int = { if (repr == other.repr) 0 // fast path - else Version.listCompare(items, other.items) + else { + val cmp = Version.listCompare(items, other.items) + // Break ties so that the order is total: versions that only differ by + // padding, separators, or build metadata are ordered by representation. + if (cmp == 0) repr.compareTo(other.repr) + else cmp + } } + + /** + * Compares versions up to the equivalence induced by version parsing. + * + * Returns `0` for versions that have the same meaning but different + * representations, like `1.0` and `1.0.0`, or `1.2+foo` and `1.2+bar` + * (Semver § 10: build metadata doesn't take part in precedence). + * + * This is *not* consistent with `equals` - it is the order to use to decide + * whether a version sits in an interval, or whether two versions can be + * reconciled. Use [[compare]] for sorting or for sorted collections. + */ + def compareSemantic(other: Version): Int = + if (repr == other.repr) 0 // fast path + else Version.listCompare(items, other.items) def isEmpty = items.forall(_.isEmpty) def withRepr(repr: String): Version = diff --git a/versions/shared/src/coursier/version/VersionCompatibility.scala b/versions/shared/src/coursier/version/VersionCompatibility.scala index 296a540..8779375 100644 --- a/versions/shared/src/coursier/version/VersionCompatibility.scala +++ b/versions/shared/src/coursier/version/VersionCompatibility.scala @@ -101,7 +101,7 @@ object VersionCompatibility { .filter(_.forall(_.isNumber)) .map(_.collect { case n: Version.Numeric => n }) .map(items => items.map(_.repr).mkString(".")) - .filter(s => Version(s).compareTo(v) <= 0) + .filter(s => Version(s).compareSemantic(v) <= 0) candidateOpt.getOrElse(version) } } @@ -134,7 +134,7 @@ object VersionCompatibility { .filter(items => items.nonEmpty && items.forall(_.isNumber) && items.forall(!_.isEmpty)) .map(_.collect { case n: Version.Numeric => n }) .map(items => items.map(_.repr).mkString(".")) - .filter(s => Version(s).compareTo(v) <= 0) + .filter(s => Version(s).compareSemantic(v) <= 0) candidateOpt.getOrElse(version) } } @@ -155,7 +155,7 @@ object VersionCompatibility { .filter(_.forall(_.isNumber)) .map(_.collect { case n: Version.Numeric => n }) .map(items => items.map(_.repr).mkString(".")) - .filter(s => Version(s).compareTo(v) <= 0) + .filter(s => Version(s).compareSemantic(v) <= 0) candidateOpt.getOrElse(version) } } diff --git a/versions/shared/src/coursier/version/VersionConstraint.scala b/versions/shared/src/coursier/version/VersionConstraint.scala index f6ffe25..1c9915a 100644 --- a/versions/shared/src/coursier/version/VersionConstraint.scala +++ b/versions/shared/src/coursier/version/VersionConstraint.scala @@ -18,13 +18,13 @@ sealed abstract class VersionConstraint extends Product with Serializable with O private lazy val compareKey = preferred.headOption.orElse(interval.from).getOrElse(Version.zero) def compare(other: VersionConstraint): Int = - compareKey.compare(other.compareKey) + compareKey.compareSemantic(other.compareKey) def isValid: Boolean = interval.isValid && preferred.forall { v => interval.contains(v) || interval.to.forall { to => - val cmp = v.compare(to) + val cmp = v.compareSemantic(to) cmp < 0 || (cmp == 0 && interval.toIncluded) } } @@ -87,7 +87,7 @@ object VersionConstraint { interval.from match { case Some(from) => allPreferred.filter { v => - val cmp = from.compare(v) + val cmp = from.compareSemantic(v) cmp < 0 || (cmp == 0 && interval.fromIncluded) } case None => diff --git a/versions/shared/src/coursier/version/VersionInterval.scala b/versions/shared/src/coursier/version/VersionInterval.scala index 476d400..8ed875f 100644 --- a/versions/shared/src/coursier/version/VersionInterval.scala +++ b/versions/shared/src/coursier/version/VersionInterval.scala @@ -12,7 +12,7 @@ case class VersionInterval( for { f <- from t <- to - cmd = f.compare(t) + cmd = f.compareSemantic(t) } yield cmd < 0 || (cmd == 0 && fromIncluded && toIncluded) fromToOrder.forall(x => x) && (from.nonEmpty || !fromIncluded) && (to.nonEmpty || !toIncluded) @@ -21,12 +21,12 @@ case class VersionInterval( def contains(version: Version): Boolean = { val fromCond = from.forall { from0 => - val cmp = from0.compare(version) + val cmp = from0.compareSemantic(version) cmp < 0 || cmp == 0 && fromIncluded } lazy val toCond = to.forall { to0 => - val cmp = version.compare(to0) + val cmp = version.compareSemantic(to0) cmp < 0 || cmp == 0 && toIncluded } @@ -49,7 +49,7 @@ case class VersionInterval( val (newFrom, newFromIncluded) = (from, other.from) match { case (Some(a), Some(b)) => - val cmp = a.compare(b) + val cmp = a.compareSemantic(b) if (cmp < 0) (Some(b), other.fromIncluded) else if (cmp > 0) (Some(a), fromIncluded) else (Some(a), fromIncluded && other.fromIncluded) @@ -62,7 +62,7 @@ case class VersionInterval( val (newTo, newToIncluded) = (to, other.to) match { case (Some(a), Some(b)) => - val cmp = a.compare(b) + val cmp = a.compareSemantic(b) if (cmp < 0) (Some(a), toIncluded) else if (cmp > 0) (Some(b), other.toIncluded) else (Some(a), toIncluded && other.toIncluded) diff --git a/versions/shared/test/src/coursier/version/VersionTests.scala b/versions/shared/test/src/coursier/version/VersionTests.scala index ac09c6e..3acaf04 100644 --- a/versions/shared/test/src/coursier/version/VersionTests.scala +++ b/versions/shared/test/src/coursier/version/VersionTests.scala @@ -6,6 +6,9 @@ import utest._ object VersionTests extends TestSuite { def compare(first: String, second: String) = + Version(first).compareSemantic(Version(second)) + + def compareTotal(first: String, second: String) = Version(first).compare(Version(second)) def increasing(versions: String*): Boolean = @@ -52,6 +55,20 @@ object VersionTests extends TestSuite { assert(compare("1.2+bar.1", "1.2+bar.2") == 0) } + test("total") { + // build metadata doesn't take part in precedence, but it still tells versions + // apart, so that the order stays total (see #13) + assert(compareTotal("1.2", "1.2+foo") < 0) + assert(compareTotal("2.0", "2.0+20130313144700") < 0) + assert(compareTotal("2.0+20130313144700", "2.0.2") < 0) + + assert(compareTotal("1.2+bar", "1.2+foo") < 0) + assert(compareTotal("1.2+bar.1", "1.2+bar.2") < 0) + + // scalajs-scalalib is published as + + assert(compareTotal("2.13.16+1.18.2", "2.13.16+1.19.0") < 0) + } + test("shouldNotParseMetadata") { test { val items = Version("1.2+bar.2").items @@ -429,6 +446,82 @@ object VersionTests extends TestSuite { assert(items == expectedItems) } + test("totalOrder") { + // https://github.com/coursier/versions/issues/13 + // Versions that compareSemantic considers equal, but that aren't equal. + // ga, final and the empty item are distinct qualifiers, and single letter + // qualifiers are only expanded when followed by a digit, so 1-ga, 1-final + // and 1.0-a aren't part of any of these groups. + val equivalent = Seq( + Seq("1", "1.0", "1.0.0", "1.0.0.0", "1.0000000000000", "01", "1-"), + Seq("1.2", "1.2+foo", "1.2+bar", "1.02"), + Seq("1.0-alpha", "1.0.0-alpha", "1.0alpha", "1.0.ALPHA"), + Seq("1.0-rc", "1.0-cr", "1.0.0-rc", "1.0rc", "1.0.RC"), + Seq("1.0-m1", "1.0-milestone-1", "1.0m1", "1.0.0-milestone-1") + ) + + test("consistentWithEquals") { + for { + group <- equivalent + first <- group + second <- group + } { + val a = Version(first) + val b = Version(second) + // compare is only 0 for versions that are equal, unlike compareSemantic + assert((a.compare(b) == 0) == (a == b)) + assert(a.compareSemantic(b) == 0) + } + } + + test("antisymmetric") { + for { + group <- equivalent + first <- group + second <- group + } { + val a = Version(first) + val b = Version(second) + assert(math.signum(a.compare(b)) == -math.signum(b.compare(a))) + } + } + + test("sortedAndHashedAgree") { + for (group <- equivalent) { + val versions = group.map(Version(_)) + val hashed = versions.toSet + val sorted = scala.collection.immutable.TreeSet.empty[Version] ++ versions + assert(hashed.size == group.distinct.length) + assert(sorted.size == hashed.size) + assert(sorted == hashed) + } + } + + test("hashCodeConsistent") { + for { + group <- equivalent + first <- group + second <- group + if Version(first) == Version(second) + } assert(Version(first).hashCode == Version(second).hashCode) + } + + test("pvp") { + // PVP orders versions by the lexicographic ordering of their components, + // so extra trailing components make a version greater + assert(compareTotal("1.0.0", "1.0.0.0") < 0) + assert(compareTotal("1", "1.0") < 0) + assert(compareTotal("2.0.1", "1.3.2") > 0) + } + + test("semanticOrderStillWins") { + // the tie-break only kicks in for versions that compareSemantic considers equal + assert(compareTotal("1.9", "1.10") < 0) + assert(compareTotal("1.0-SNAPSHOT", "1.0") < 0) + assert(compareTotal("1.0.0.0.0.1", "1.0.1") < 0) + } + } + test("isStable") { assert(Version("1.2.3").isStable) assert(Version("1.2.3-3").isStable) From 103a2191ff8c488409490af9e311f752200416fb Mon Sep 17 00:00:00 2001 From: Alex Archambault Date: Fri, 4 Sep 2026 13:04:49 +0200 Subject: [PATCH 2/3] Make release qualifiers semantically equivalent to no qualifier `ga` and `final` stand for the absence of a qualifier, so `1.1.0`, `1.1.0.GA` and `1.1.0.Final` all mean the same version. `compareSemantic` now returns 0 for them, which is what interval and constraint logic needs. `compare` still tells them apart, in the order the coursier documentation lists, so that it stays total and consistent with equality. The tests that pin that documented order now use it rather than `compareSemantic`. Also drop `1.0-a` from an equivalence group: since the ordering rework, a lone `a` is a plain literal, and only expands to `alpha` when followed by a digit. Co-Authored-By: Claude Opus 5 --- README.md | 7 +- README.template.md | 7 +- .../shared/src/coursier/version/Version.scala | 44 +++++++++++- .../src/coursier/version/VersionTests.scala | 70 ++++++++++++++----- 4 files changed, 103 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index cce1010..8380205 100644 --- a/README.md +++ b/README.md @@ -68,13 +68,16 @@ representation, so that sorted collections and hash-based ones agree on which ve distinct. Use `compareSemantic` to compare versions up to that equivalence - it returns `0` for versions -that only differ by padding, separators, or build metadata. This is the order that decides -whether a version sits in an interval, or whether two constraints can be reconciled: +that only differ by padding, separators, build metadata, or a release qualifier like `Final` or +`GA`. This is the order that decides whether a version sits in an interval, or whether two +constraints can be reconciled: ```scala Version("1.0").compare(Version("1.0.0")) // res: Int = -2 Version("1.0").compareSemantic(Version("1.0.0")) // res: Int = 0 +Version("1.1.0").compareSemantic(Version("1.1.0.Final")) +// res: Int = 0 ``` ## `VersionConstraint` diff --git a/README.template.md b/README.template.md index 39acb6f..6d34d85 100644 --- a/README.template.md +++ b/README.template.md @@ -61,13 +61,16 @@ representation, so that sorted collections and hash-based ones agree on which ve distinct. Use `compareSemantic` to compare versions up to that equivalence - it returns `0` for versions -that only differ by padding, separators, or build metadata. This is the order that decides -whether a version sits in an interval, or whether two constraints can be reconciled: +that only differ by padding, separators, build metadata, or a release qualifier like `Final` or +`GA`. This is the order that decides whether a version sits in an interval, or whether two +constraints can be reconciled: ```scala Version("1.0").compare(Version("1.0.0")) // res: Int = -2 Version("1.0").compareSemantic(Version("1.0.0")) // res: Int = 0 +Version("1.1.0").compareSemantic(Version("1.1.0.Final")) +// res: Int = 0 ``` ## `VersionConstraint` diff --git a/versions/shared/src/coursier/version/Version.scala b/versions/shared/src/coursier/version/Version.scala index f18f1fd..202cbbf 100644 --- a/versions/shared/src/coursier/version/Version.scala +++ b/versions/shared/src/coursier/version/Version.scala @@ -43,8 +43,9 @@ case class Version(repr: String) extends Ordered[Version] { * Compares versions up to the equivalence induced by version parsing. * * Returns `0` for versions that have the same meaning but different - * representations, like `1.0` and `1.0.0`, or `1.2+foo` and `1.2+bar` - * (Semver § 10: build metadata doesn't take part in precedence). + * representations, like `1.0` and `1.0.0`, `1.1.0` and `1.1.0.Final`, or + * `1.2+foo` and `1.2+bar` (Semver § 10: build metadata doesn't take part in + * precedence). * * This is *not* consistent with `equals` - it is the order to use to decide * whether a version sits in an interval, or whether two versions can be @@ -52,7 +53,14 @@ case class Version(repr: String) extends Ordered[Version] { */ def compareSemantic(other: Version): Int = if (repr == other.repr) 0 // fast path - else Version.listCompare(items, other.items) + else Version.listCompare(semanticItems, other.semanticItems) + + private var semanticItems0: Vector[Version.Item] = null + private def semanticItems: Vector[Version.Item] = { + if (semanticItems0 == null) + semanticItems0 = Version.semanticItems(items) + semanticItems0 + } def isEmpty = items.forall(_.isEmpty) def withRepr(repr: String): Version = @@ -152,6 +160,16 @@ object Version { override def compareToEmpty = level.compare(0) def isPreRelease: Boolean = level < Tag.emptyLevel + + /** + * Whether this tag stands for the absence of a qualifier, like the `Final` of + * `3.10.1.Final` or the `GA` of `1.2.3.GA`. + * + * [[Version.compare]] orders those after the empty item, so that no two versions compare + * equal, while [[Version.compareSemantic]] treats them as the empty tag. + */ + def isReleaseEquivalent: Boolean = + level == Tag.emptyLevel || level == Tag.gaLevel || level == Tag.finalLevel def compareTag(other: Tag): Int = { val levelComp = level.compare(other.level) if (levelComp == 0 && level == Tag.otherLevel) value.compareToIgnoreCase(other.value) @@ -312,6 +330,26 @@ object Version { first +: tokens.toVector.map(_._2) } + private val emptyTag = Tag("") + + /** + * Replaces the tags that stand for the absence of a qualifier - `ga` and `final` - by the + * empty tag, so that `1.1.0`, `1.1.0.GA` and `1.1.0.Final` compare equal. + * + * Those tags are ordered right after the empty item, and nothing else sits between them, so + * that collapsing them keeps the order of everything else unchanged. + */ + private def semanticItems(items: Vector[Item]): Vector[Item] = { + def isReleaseEquivalent(item: Item): Boolean = + item match { + case t: Tag => t.isReleaseEquivalent + case _ => false + } + if (items.exists(isReleaseEquivalent)) + items.map(item => if (isReleaseEquivalent(item)) emptyTag else item) + else items + } + // before comparing two versions pad the number parts to the equal number of digits // for example, 1-ga, and 1.0.0 comparison will be adjusted first to 1.0.0-ga and 1.0.0. def listCompare(first0: Vector[Item], second0: Vector[Item]): Int = { diff --git a/versions/shared/test/src/coursier/version/VersionTests.scala b/versions/shared/test/src/coursier/version/VersionTests.scala index 3acaf04..e5ee8b0 100644 --- a/versions/shared/test/src/coursier/version/VersionTests.scala +++ b/versions/shared/test/src/coursier/version/VersionTests.scala @@ -14,6 +14,9 @@ object VersionTests extends TestSuite { def increasing(versions: String*): Boolean = versions.iterator.sliding(2).withPartial(false).forall{case Seq(a, b) => compare(a, b) < 0 } + def increasingTotal(versions: String*): Boolean = + versions.iterator.sliding(2).withPartial(false).forall{case Seq(a, b) => compareTotal(a, b) < 0 } + val tests = Tests { @@ -216,18 +219,23 @@ object VersionTests extends TestSuite { assert(compare("1-rc", "1-cr" ) == 0) assert(compare("1-rc", "1-snapshot" ) < 0) assert(compare("1-snapshot", "1" ) < 0) - // ga, final and sp all denote releases, and go after the empty item, in that order. - // None of them is equivalent to another, nor to the empty item. - assert(compare("1", "1-ga" ) < 0) - assert(compare("1", "1.ga.0.ga" ) < 0) - assert(compare("1.0", "1-ga" ) < 0) - assert(compare("1", "1-ga.ga" ) < 0) - assert(compare("1", "1-ga-ga" ) < 0) - assert(compare("A", "A.ga.ga" ) < 0) - assert(compare("A", "A-ga-ga" ) < 0) - assert(compare("1-ga", "1-final" ) < 0) + // ga, final and sp all denote releases, and the total order puts them after the empty + // item, in that order, so that no two of them compare equal. ga and final stand for the + // absence of a qualifier though, so they don't change what a version means. + assert(compareTotal("1", "1-ga" ) < 0) + assert(compareTotal("1", "1.ga.0.ga" ) < 0) + assert(compareTotal("1.0", "1-ga" ) < 0) + assert(compareTotal("1", "1-ga.ga" ) < 0) + assert(compareTotal("1", "1-ga-ga" ) < 0) + assert(compareTotal("A", "A.ga.ga" ) < 0) + assert(compareTotal("A", "A-ga-ga" ) < 0) + assert(compareTotal("1-ga", "1-final" ) < 0) + assert(compareTotal("1", "1-final" ) < 0) + assert(compare("1", "1-ga" ) == 0) + assert(compare("1", "1-ga-ga" ) == 0) + assert(compare("1-ga", "1-final" ) == 0) + // sp is a release of its own, it isn't equivalent to the empty item assert(compare("1-final", "1-sp" ) < 0) - assert(compare("1", "1-final" ) < 0) assert(compare("1", "1-sp" ) < 0) assert(compare("2.12.4-bin-typelevel-4", "2.12.4" ) > 0) @@ -350,8 +358,10 @@ object VersionTests extends TestSuite { test("qualifierVersusNumberOrdering") { assert(compare("1-ga", "1-1" ) < 0) assert(compare("1.ga", "1.1" ) < 0) - assert(compare("1-ga", "1.0" ) > 0) - assert(compare("1.ga", "1.0" ) > 0) + // 1-ga and 1.0 mean the same version, only the total order tells them apart + assert(compareTotal("1-ga", "1.0" ) > 0) + assert(compareTotal("1.ga", "1.0" ) > 0) + assert(compare("1-ga", "1.0" ) == 0) // 1-0-1 has a longer numeric prefix, so 1-ga-1 is padded to 1-0-0-ga-1, and the // comparison is settled at the third item, before reaching the ga tag @@ -455,9 +465,11 @@ object VersionTests extends TestSuite { val equivalent = Seq( Seq("1", "1.0", "1.0.0", "1.0.0.0", "1.0000000000000", "01", "1-"), Seq("1.2", "1.2+foo", "1.2+bar", "1.02"), - Seq("1.0-alpha", "1.0.0-alpha", "1.0alpha", "1.0.ALPHA"), + // a lone a is a plain literal, not alpha - it only expands when followed by a digit + Seq("1.0-alpha", "1.0.0-alpha", "1.0alpha", "1.0.ALPHA", "1.0_alpha"), Seq("1.0-rc", "1.0-cr", "1.0.0-rc", "1.0rc", "1.0.RC"), - Seq("1.0-m1", "1.0-milestone-1", "1.0m1", "1.0.0-milestone-1") + Seq("1.0-m1", "1.0-milestone-1", "1.0m1", "1.0.0-milestone-1"), + Seq("1.1.0", "1.1", "1.1.0.Final", "1.1.0.GA", "1.1.0-final", "1.1.0-ga") ) test("consistentWithEquals") { @@ -514,6 +526,24 @@ object VersionTests extends TestSuite { assert(compareTotal("2.0.1", "1.3.2") > 0) } + test("releaseQualifiers") { + // ga and final stand for the absence of a qualifier, so they don't change + // what a version means + assert(compare("1.1.0", "1.1.0.Final") == 0) + assert(compare("1.1.0", "1.1.0.GA") == 0) + assert(compare("1.1.0.GA", "1.1.0.Final") == 0) + assert(compare("4.1.100.Final", "4.1.100") == 0) + + // compare still tells them apart, in the documented qualifier order + assert(compareTotal("1.1.0", "1.1.0.GA") < 0) + assert(compareTotal("1.1.0.GA", "1.1.0.Final") < 0) + assert(compareTotal("1.1.0.Final", "1.1.0.sp") < 0) + + // only ga and final are collapsed, sp is a release of its own + assert(compare("1.1.0", "1.1.0.sp") < 0) + assert(compare("1.1.0-SNAPSHOT", "1.1.0.Final") < 0) + } + test("semanticOrderStillWins") { // the tie-break only kicks in for versions that compareSemantic considers equal assert(compareTotal("1.9", "1.10") < 0) @@ -553,8 +583,10 @@ object VersionTests extends TestSuite { // "1.1-rc goes before 1.1-final (qualifier rc before final)" assert(compare("1.1-rc", "1.1-final") < 0) - // "1.1 goes before 1.1-final (empty item before qualifier final)" - assert(compare("1.1", "1.1-final") < 0) + // "1.1 goes before 1.1-final (empty item before qualifier final)" - in the total order. + // final stands for the absence of a qualifier, so both mean the same version. + assert(compareTotal("1.1", "1.1-final") < 0) + assert(compare("1.1", "1.1-final") == 0) // "1.1 goes before 1.1a (empty item before literal a)" assert(compare("1.1", "1.1a") < 0) @@ -584,7 +616,9 @@ object VersionTests extends TestSuite { // milestone (or m if directly followed by a digit), cr or rc, snapshot, then the empty // item itself, ga, final, sp. They all go before the plain literal items." test("orderingDocQualifierList") { - assert(increasing( + // that list is the total order - ga and final are equivalent to the empty item for + // compareSemantic, see the totalOrder tests + assert(increasingTotal( "1-alpha", "1-beta", "1-milestone", "1-rc", "1-snapshot", "1", "1-ga", "1-final", "1-sp", "1-zzz", "1-1" )) From 6c11c97c9679d956ef1a7118303f96918126b734 Mon Sep 17 00:00:00 2001 From: Alex Archambault Date: Fri, 4 Sep 2026 12:11:52 +0200 Subject: [PATCH 3/3] Handle Maven-style release qualifiers in compatibility checks Recognize semantic-version pre-releases while allowing release qualifiers such as `Final` and `v20191220` to participate in compatibility checks. Co-authored-by: Claude Opus 5 --- .../version/VersionCompatibility.scala | 50 ++++++++++++- .../version/VersionCompatibilityTests.scala | 71 +++++++++++++++++++ 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/versions/shared/src/coursier/version/VersionCompatibility.scala b/versions/shared/src/coursier/version/VersionCompatibility.scala index 8779375..7edfdc5 100644 --- a/versions/shared/src/coursier/version/VersionCompatibility.scala +++ b/versions/shared/src/coursier/version/VersionCompatibility.scala @@ -25,6 +25,52 @@ sealed abstract class VersionCompatibility { object VersionCompatibility { + /** + * Whether `version` carries a pre-release qualifier. + * + * Two kinds of items count as pre-releases: + * - pre-release qualifiers, wherever they appear (`1.2.3-RC1`, `2.13.0-M3`, `1.2.3.RC1`), + * - plain literals right after a `-`, which is where semantic versioning puts + * pre-releases (`1.0.0-preview`). + * + * Release qualifiers and plain literals in the fourth, dot-separated segment of a + * Maven-style version are deliberately *not* pre-releases: `3.10.1.Final` and + * `9.4.25.v20191220` are releases. + */ + private def hasPreReleaseQualifier(version: Version): Boolean = { + val (first, rest) = Version.Tokenizer(version.repr) + def preRelease(item: Version.Item, afterHyphen: Boolean): Boolean = + item match { + case t: Version.Tag => + if (t.isQualifier) t.isPreRelease else afterHyphen + case _ => false + } + preRelease(first, afterHyphen = false) || + rest.exists { + case (sep, item) => preRelease(item, sep == Version.Tokenizer.Hyphen) + } + } + + /** + * Whether `version` can take part in a semantic versioning compatibility check as a constraint. + * + * Its significant part (the major number, or major and minor for 0.x versions) must be numeric, + * the rest must be made of numbers, qualifiers and build metadata, and it must not be a + * pre-release - per semantic versioning, a constraint like `1.2.3-RC1` only accepts itself. + */ + private def isSemVerComparable(version: Version, significantPartLength: Int): Boolean = { + val items = version.items + items.lengthCompare(significantPartLength) >= 0 && + items.take(significantPartLength).forall(_.isNumber) && + items.drop(significantPartLength).forall { + case _: Version.Numeric => true + case _: Version.BuildMetadata => true + case _: Version.Tag => true + case _ => false // Min / Max + } && + !hasPreReleaseQualifier(version) + } + case object Default extends VersionCompatibility { def isCompatible(constraint: String, version: String): Boolean = PackVer.isCompatible(constraint, version) @@ -85,7 +131,7 @@ object VersionCompatibility { if (c.interval == VersionInterval.zero) c.preferred.exists { wanted => val toCompare = significativePartLength(v) - wanted.items.forall(_.isNumber) && + isSemVerComparable(wanted, toCompare) && wanted.items.take(toCompare) == v.items.take(toCompare) && { import Ordering.Implicits._ wanted.items.drop(toCompare) <= v.items.drop(toCompare) @@ -118,7 +164,7 @@ object VersionCompatibility { val v = Version(version) if (c.interval == VersionInterval.zero) c.preferred.exists { wanted => - wanted.items.forall(_.isNumber) && + isSemVerComparable(wanted, 1) && wanted.items.take(1) == v.items.take(1) && v.items.take(1).exists(!_.isEmpty) && { import Ordering.Implicits._ diff --git a/versions/shared/test/src/coursier/version/VersionCompatibilityTests.scala b/versions/shared/test/src/coursier/version/VersionCompatibilityTests.scala index b891ce7..a50c221 100644 --- a/versions/shared/test/src/coursier/version/VersionCompatibilityTests.scala +++ b/versions/shared/test/src/coursier/version/VersionCompatibilityTests.scala @@ -115,6 +115,77 @@ object VersionCompatibilityTests extends TestSuite { } } + test("maven-style qualifiers") { + + // https://github.com/coursier/versions/issues/10 + // Netty and Jetty put a qualifier in a fourth, dot-separated segment. Those are releases, + // not pre-releases, so compatibility has to be decided on the numeric part. + + test("early semver") { + implicit val compat = VersionCompatibility.EarlySemVer + + test("netty") { + compatible("3.7.0.Final", "3.10.1.Final") + compatible("4.1.100.Final", "4.1.104.Final") + incompatible("3.10.1.Final", "3.7.0.Final") + incompatible("3.7.0.Final", "4.0.0.Final") + } + test("jetty") { + compatible("9.2.14.v20151106", "9.4.25.v20191220") + incompatible("9.4.25.v20191220", "9.2.14.v20151106") + incompatible("9.2.14.v20151106", "10.0.0.v20200101") + } + test("qualifier on one side only") { + compatible("4.1.100", "4.1.104.Final") + compatible("4.1.100.Final", "4.1.104") + } + test("other release qualifiers") { + compatible("1.2.3.RELEASE", "1.5.0.RELEASE") + compatible("1.2.3.GA", "1.5.0.GA") + compatible("8.0.1.jre8", "8.4.1.jre8") + } + test("still significant for 0.x") { + compatible("0.1.1.Final", "0.1.2.Final") + incompatible("0.1.1.Final", "0.2.0.Final") + } + } + + test("semver spec") { + implicit val compat = VersionCompatibility.SemVerSpec + + test { + compatible("3.7.0.Final", "3.10.1.Final") + } + test { + compatible("9.2.14.v20151106", "9.4.25.v20191220") + } + test { + incompatible("3.10.1.Final", "3.7.0.Final") + } + } + + test("pre-releases stay incompatible") { + // a qualifier right after a '-' is a semver pre-release, and a constraint on a + // pre-release only accepts itself + val compatibilities = Seq(VersionCompatibility.EarlySemVer, VersionCompatibility.SemVerSpec) + val preReleases = Seq( + "1.2.3-RC1", + "1.2.3-M3", + "1.2.3-alpha1", + "1.2.3-beta2", + "1.2.3-SNAPSHOT", + "1.2.3-milestone2", + // well-known markers count as pre-releases wherever they show up + "1.2.3.RC1", + "1.2.3.SNAPSHOT" + ) + for (compat <- compatibilities; wanted <- preReleases) { + val compatible = compat.isCompatible(wanted, "1.9.9") + Predef.assert(!compatible, s"Expected '1.9.9' not to be compatible with '$wanted' per $compat") + } + } + } + test("package versioning") { implicit val compat = VersionCompatibility.PackVer