From 9d87715100c99cddabf2845e0ec8a2cfa2e37bcd Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Wed, 5 Aug 2020 00:08:23 -0400 Subject: [PATCH] Automatic eviction warning This extracts info.versionScheme from POM and uses that to guide the eviction report instead of taking a stab in the dark that all Scala libraries are using pvp. --- .../mavenint/SbtPomExtraProperties.java | 1 + .../librarymanagement/VersionSchemes.scala | 43 +++++++++++ .../librarymanagement/EvictionWarning.scala | 34 ++++++++- .../sbt/librarymanagement/VersionNumber.scala | 75 ++++++++++++++++++- .../librarymanagement/VersionNumberSpec.scala | 44 +++++++---- 5 files changed, 178 insertions(+), 19 deletions(-) create mode 100644 core/src/main/scala/sbt/internal/librarymanagement/VersionSchemes.scala diff --git a/core/src/main/java/sbt/internal/librarymanagement/mavenint/SbtPomExtraProperties.java b/core/src/main/java/sbt/internal/librarymanagement/mavenint/SbtPomExtraProperties.java index 83addbc46..8bc61d549 100644 --- a/core/src/main/java/sbt/internal/librarymanagement/mavenint/SbtPomExtraProperties.java +++ b/core/src/main/java/sbt/internal/librarymanagement/mavenint/SbtPomExtraProperties.java @@ -13,6 +13,7 @@ public class SbtPomExtraProperties { public static final String POM_SCALA_VERSION = "scalaVersion"; public static final String POM_SBT_VERSION = "sbtVersion"; public static final String POM_API_KEY = "info.apiURL"; + public static final String VERSION_SCHEME_KEY = "info.versionScheme"; public static final String LICENSE_COUNT_KEY = "license.count"; diff --git a/core/src/main/scala/sbt/internal/librarymanagement/VersionSchemes.scala b/core/src/main/scala/sbt/internal/librarymanagement/VersionSchemes.scala new file mode 100644 index 000000000..7e2179f34 --- /dev/null +++ b/core/src/main/scala/sbt/internal/librarymanagement/VersionSchemes.scala @@ -0,0 +1,43 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt +package internal +package librarymanagement + +import sbt.internal.librarymanagement.mavenint.SbtPomExtraProperties +import sbt.librarymanagement.ModuleID + +// See APIMappings.scala +private[sbt] object VersionSchemes { + final val EarlySemVer = "early-semver" + final val SemVerSpec = "semver-spec" + final val PackVer = "pvp" + + def validateScheme(value: String): Unit = + value match { + case EarlySemVer | SemVerSpec | PackVer => () + case "semver" => + sys.error( + s"""'semver' is ambiguous. + |Based on the Semantic Versioning 2.0.0, 0.y.z updates are all initial development and thus + |0.6.0 and 0.6.1 would NOT maintain any compatibility, but in Scala ecosystem it is + |common to start adopting binary compatibility even in 0.y.z releases. + | + |Specify 'early-semver' for the early variant. + |Specify 'semver-spec' for the spec-correct SemVer.""".stripMargin + ) + case x => sys.error(s"unknown version scheme: $x") + } + + /** info.versionScheme property will be included into POM after sbt 1.4.0. + */ + def extractFromId(mid: ModuleID): Option[String] = extractFromExtraAttributes(mid.extraAttributes) + + def extractFromExtraAttributes(extraAttributes: Map[String, String]): Option[String] = + extraAttributes.get(SbtPomExtraProperties.VERSION_SCHEME_KEY) +} diff --git a/core/src/main/scala/sbt/librarymanagement/EvictionWarning.scala b/core/src/main/scala/sbt/librarymanagement/EvictionWarning.scala index c090cd2ad..e8dff4e6f 100644 --- a/core/src/main/scala/sbt/librarymanagement/EvictionWarning.scala +++ b/core/src/main/scala/sbt/librarymanagement/EvictionWarning.scala @@ -3,6 +3,7 @@ package sbt.librarymanagement import collection.mutable import Configurations.Compile import ScalaArtifacts.{ LibraryID, CompilerID } +import sbt.internal.librarymanagement.VersionSchemes import sbt.util.Logger import sbt.util.ShowLines @@ -136,6 +137,17 @@ object EvictionWarningOptions { } } + lazy val guessEarlySemVer + : PartialFunction[(ModuleID, Option[ModuleID], Option[ScalaModuleInfo]), Boolean] = { + case (m1, Some(m2), _) => + (m1.revision, m2.revision) match { + case (VersionNumber(ns1, ts1, es1), VersionNumber(ns2, ts2, es2)) => + VersionNumber.EarlySemVer + .isCompatible(VersionNumber(ns1, ts1, es1), VersionNumber(ns2, ts2, es2)) + case _ => false + } + } + lazy val guessFalse : PartialFunction[(ModuleID, Option[ModuleID], Option[ScalaModuleInfo]), Boolean] = { case (_, _, _) => false @@ -290,9 +302,25 @@ object EvictionWarning { var binaryIncompatibleEvictionExists = false def guessCompatible(p: EvictionPair): Boolean = p.evicteds forall { r => - options.guessCompatible( - (r.module, p.winner map { _.module }, module.scalaModuleInfo) - ) + val winnerOpt = p.winner map { _.module } + val extraAttributes = (p.winner match { + case Some(r) => r.extraAttributes + case _ => Map.empty + }) ++ (winnerOpt match { + case Some(w) => w.extraAttributes + case _ => Map.empty + }) + val schemeOpt = VersionSchemes.extractFromExtraAttributes(extraAttributes) + val f = (winnerOpt, schemeOpt) match { + case (Some(_), Some(VersionSchemes.EarlySemVer)) => + EvictionWarningOptions.guessEarlySemVer + case (Some(_), Some(VersionSchemes.SemVerSpec)) => + EvictionWarningOptions.guessSemVer + case (Some(_), Some(VersionSchemes.PackVer)) => + EvictionWarningOptions.guessSecondSegment + case _ => options.guessCompatible(_) + } + f((r.module, winnerOpt, module.scalaModuleInfo)) } pairs foreach { case p if isScalaArtifact(module, p.organization, p.name) => diff --git a/core/src/main/scala/sbt/librarymanagement/VersionNumber.scala b/core/src/main/scala/sbt/librarymanagement/VersionNumber.scala index 2437466cc..c8bef483d 100644 --- a/core/src/main/scala/sbt/librarymanagement/VersionNumber.scala +++ b/core/src/main/scala/sbt/librarymanagement/VersionNumber.scala @@ -167,9 +167,19 @@ object VersionNumber { * Also API compatibility is expected even when the first segment is zero. */ object SecondSegment extends VersionNumberCompatibility { + def name: String = "Second Segment Variant" + def isCompatible(v1: VersionNumber, v2: VersionNumber): Boolean = + PackVer.isCompatible(v1, v2) + } + + /** A variant of SemVar that seems to be common among the Scala libraries. + * The second segment (y in x.y.z) increments breaks the binary compatibility even when x > 0. + * Also API compatibility is expected even when the first segment is zero. + */ + object PackVer extends VersionNumberCompatibility { import SemVer._ - def name: String = "Second Segment Variant" + def name: String = "Package Versioning Policy" def isCompatible(v1: VersionNumber, v2: VersionNumber): Boolean = doIsCompat(dropBuildMetadata(v1), dropBuildMetadata(v2)) @@ -182,6 +192,69 @@ object VersionNumber { } } } + + /** A variant of SemVar that enforces API compatibility when the first segment is zero. + */ + object EarlySemVer extends VersionNumberCompatibility { + import SemVer._ + + def name: String = "Early Semantic Versioning" + + /* Quotes of parts of the rules in the SemVer Spec relevant to compatibility checking: + * + * Rule 2: + * > A normal version number MUST take the form X.Y.Z + * + * Rule 6: + * > Patch version Z (x.y.Z | x > 0) MUST be incremented if only backwards compatible bug fixes are introduced. + * + * Rule 7: + * > Minor version Y (x.Y.z | x > 0) MUST be incremented if new, backwards compatible functionality is introduced. + * + * Rule 8: + * > Major version X (X.y.z | X > 0) MUST be incremented if any backwards incompatible changes are introduced. + * + * Rule 9: + * > A pre-release version MAY be denoted by appending a hyphen and a series of + * > dot separated identifiers immediately following the patch version. + * > Identifiers MUST comprise only ASCII alphanumerics and hyphen [0-9A-Za-z-]. + * > Identifiers MUST NOT be empty. + * > Numeric identifiers MUST NOT include leading zeroes. + * > Pre-release versions have a lower precedence than the associated normal version. + * > A pre-release version indicates that the version is unstable and might not satisfy the + * > intended compatibility requirements as denoted by its associated normal version. + * > Examples: 1.0.0-alpha, 1.0.0-alpha.1, 1.0.0-0.3.7, 1.0.0-x.7.z.92. + * + * Rule 10: + * > Build metadata MAY be denoted by appending a plus sign and a series of + * > dot separated identifiers immediately following the patch or pre-release version. + * > Identifiers MUST comprise only ASCII alphanumerics and hyphen [0-9A-Za-z-]. + * > Identifiers MUST NOT be empty. + * > Build metadata SHOULD be ignored when determining version precedence. + * > Thus two versions that differ only in the build metadata, have the same precedence. + * > Examples: 1.0.0-alpha+001, 1.0.0+20130313144700, 1.0.0-beta+exp.sha.5114f85. + * + * Rule 10 means that build metadata is never considered for compatibility + * we'll enforce this immediately by dropping them from both versions + * Rule 2 we enforce with custom extractors. + * Rule 6, 7 & 8 means version compatibility is determined by comparing the two X values + * Rule 9.. + * Dale thinks means pre-release versions are fully equals checked.. + * Eugene thinks means pre-releases before 1.0.0 are not compatible, if not they are.. + * Rule 4 is modified in this variant. + */ + def isCompatible(v1: VersionNumber, v2: VersionNumber): Boolean = + doIsCompat(dropBuildMetadata(v1), dropBuildMetadata(v2)) + + private[this] def doIsCompat(v1: VersionNumber, v2: VersionNumber): Boolean = + (v1, v2) match { + case (NormalVersion(0, _, 0), NormalVersion(0, _, 0)) => v1 == v2 + case (NormalVersion(0, y1, _), NormalVersion(0, y2, _)) => y1 == y2 + case (NormalVersion(_, 0, 0), NormalVersion(_, 0, 0)) => v1 == v2 // R9 maybe? + case (NormalVersion(x1, _, _), NormalVersion(x2, _, _)) => x1 == x2 // R6, R7 & R8 + case _ => false + } + } } trait VersionNumberCompatibility { diff --git a/core/src/test/scala/sbt/librarymanagement/VersionNumberSpec.scala b/core/src/test/scala/sbt/librarymanagement/VersionNumberSpec.scala index eab52f6c9..7a7038fba 100644 --- a/core/src/test/scala/sbt/librarymanagement/VersionNumberSpec.scala +++ b/core/src/test/scala/sbt/librarymanagement/VersionNumberSpec.scala @@ -4,7 +4,7 @@ import org.scalatest.{ FreeSpec, Inside, Matchers } // This is a specification to check VersionNumber and VersionNumberCompatibility. class VersionNumberSpec extends FreeSpec with Matchers with Inside { - import VersionNumber.{ SemVer, SecondSegment } + import VersionNumber.{ EarlySemVer, SemVer, PackVer } version("1") { v => assertParsesTo(v, Seq(1), Seq(), Seq()) @@ -28,10 +28,15 @@ class VersionNumberSpec extends FreeSpec with Matchers with Inside { assertIsNotCompatibleWith(v, "2.0.0", SemVer) assertIsNotCompatibleWith(v, "1.0.0-M1", SemVer) - assertIsCompatibleWith(v, "1.0.1", SecondSegment) - assertIsNotCompatibleWith(v, "1.1.1", SecondSegment) - assertIsNotCompatibleWith(v, "2.0.0", SecondSegment) - assertIsNotCompatibleWith(v, "1.0.0-M1", SecondSegment) + assertIsCompatibleWith(v, "1.0.1", EarlySemVer) + assertIsCompatibleWith(v, "1.1.1", EarlySemVer) + assertIsNotCompatibleWith(v, "2.0.0", EarlySemVer) + assertIsNotCompatibleWith(v, "1.0.0-M1", EarlySemVer) + + assertIsCompatibleWith(v, "1.0.1", PackVer) + assertIsNotCompatibleWith(v, "1.1.1", PackVer) + assertIsNotCompatibleWith(v, "2.0.0", PackVer) + assertIsNotCompatibleWith(v, "1.0.0-M1", PackVer) } version("1.0.0.0") { v => @@ -49,9 +54,13 @@ class VersionNumberSpec extends FreeSpec with Matchers with Inside { assertIsNotCompatibleWith(v, "0.12.1", SemVer) assertIsNotCompatibleWith(v, "0.12.1-M1", SemVer) - assertIsNotCompatibleWith(v, "0.12.0-RC1", SecondSegment) - assertIsCompatibleWith(v, "0.12.1", SecondSegment) - assertIsCompatibleWith(v, "0.12.1-M1", SecondSegment) + assertIsNotCompatibleWith(v, "0.12.0-RC1", EarlySemVer) + assertIsCompatibleWith(v, "0.12.1", EarlySemVer) + assertIsCompatibleWith(v, "0.12.1-M1", EarlySemVer) + + assertIsNotCompatibleWith(v, "0.12.0-RC1", PackVer) + assertIsCompatibleWith(v, "0.12.1", PackVer) + assertIsCompatibleWith(v, "0.12.1-M1", PackVer) } version("0.1.0-SNAPSHOT") { v => @@ -62,9 +71,13 @@ class VersionNumberSpec extends FreeSpec with Matchers with Inside { assertIsNotCompatibleWith(v, "0.1.0", SemVer) assertIsCompatibleWith(v, "0.1.0-SNAPSHOT+001", SemVer) - assertIsCompatibleWith(v, "0.1.0-SNAPSHOT", SecondSegment) - assertIsNotCompatibleWith(v, "0.1.0", SecondSegment) - assertIsCompatibleWith(v, "0.1.0-SNAPSHOT+001", SecondSegment) + assertIsCompatibleWith(v, "0.1.0-SNAPSHOT", EarlySemVer) + assertIsNotCompatibleWith(v, "0.1.0", EarlySemVer) + assertIsCompatibleWith(v, "0.1.0-SNAPSHOT+001", EarlySemVer) + + assertIsCompatibleWith(v, "0.1.0-SNAPSHOT", PackVer) + assertIsNotCompatibleWith(v, "0.1.0", PackVer) + assertIsCompatibleWith(v, "0.1.0-SNAPSHOT+001", PackVer) } version("0.1.0-M1") { v => @@ -86,7 +99,7 @@ class VersionNumberSpec extends FreeSpec with Matchers with Inside { assertParsesTo(v, Seq(2, 10, 4), Seq("20140115", "000117", "b3a", "sources"), Seq()) assertCascadesTo(v, Seq("2.10.4-20140115-000117-b3a-sources", "2.10.4", "2.10")) assertIsCompatibleWith(v, "2.0.0", SemVer) - assertIsNotCompatibleWith(v, "2.0.0", SecondSegment) + assertIsNotCompatibleWith(v, "2.0.0", PackVer) } version("20140115000117-b3a-sources") { v => @@ -187,9 +200,10 @@ class VersionNumberSpec extends FreeSpec with Matchers with Inside { ) = { val prefix = if (expectOutcome) "should" else "should NOT" val compatibilityStrategy = vnc match { - case SemVer => "SemVer" - case SecondSegment => "SecondSegment" - case _ => val s = vnc.name; if (s contains " ") s""""$s"""" else s + case SemVer => "SemVer" + case PackVer => "PackVer" + case EarlySemVer => "EarlySemVer" + case _ => val s = vnc.name; if (s contains " ") s""""$s"""" else s } s"$prefix be $compatibilityStrategy compatible with $v2" in { vnc.isCompatible(VersionNumber(v1.value), VersionNumber(v2)) shouldBe expectOutcome