From bf33f51d10f23631a03cf5a7b3162f6bc736776f Mon Sep 17 00:00:00 2001 From: eugene yokota Date: Sun, 1 Mar 2026 05:27:40 -0500 Subject: [PATCH] [2.x] feat: Add allowMismatchScala setting (#8804) (#8849) sbt 2.x allows `dependsOn(...)` between subprojects with mismatched Scala versions without any warning or error. This can lead to confusing classpath issues at compile or runtime, especially now that Scala 3.8+ has dropped backward TASTy compatibility with 2.13. Per review feedback, move the Scala version mismatch check from compileTask to projectDependenciesTask, where PR #8681 already handles Scala version mixing logic. This provides earlier detection and keeps the validation co-located with cross-version resolution. Generated-by: Copilot Co-authored-by: dev-miro26 <121471669+dev-miro26@users.noreply.github.com> --- main/src/main/scala/sbt/Defaults.scala | 91 +++++++++++-------- main/src/main/scala/sbt/Keys.scala | 1 + .../scala/sbt/internal/ClasspathImpl.scala | 10 +- .../sbt/internal/ClasspathImplTest.scala | 54 +++++++++++ .../build.sbt | 1 + .../stdlib-3.8/build.sbt | 1 + .../project/allow-mismatch-scala/build.sbt | 23 +++++ .../project/allow-mismatch-scala/test | 9 ++ .../src-scala-binary-version/build.sbt | 2 +- 9 files changed, 151 insertions(+), 41 deletions(-) create mode 100644 main/src/test/scala/sbt/internal/ClasspathImplTest.scala create mode 100644 sbt-app/src/sbt-test/project/allow-mismatch-scala/build.sbt create mode 100644 sbt-app/src/sbt-test/project/allow-mismatch-scala/test diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index e9dd6b511..243f10602 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -182,6 +182,7 @@ object Defaults extends BuildCommon { autoScalaLibrary :== true, managedScalaInstance :== true, allowUnsafeScalaLibUpgrade :== false, + allowMismatchScala :== false, classpathEntryDefinesClass := Def.uncached { (file: File) => sys.error("use classpathEntryDefinesClassVF instead") }, @@ -4180,10 +4181,12 @@ object Classpaths { def projectDependenciesTask: Initialize[Task[Seq[ModuleID]]] = Def.task { + val sv = scalaVersion.value val sbv = scalaBinaryVersion.value val ref = thisProjectRef.value val data = settingsData.value val deps = buildDependencies.value + val allow = allowMismatchScala.value deps .classpath(ref) .flatMap: dep => @@ -4192,45 +4195,55 @@ object Classpaths { depSBV <- (dep.project / scalaBinaryVersion).get(data) depCross <- (dep.project / crossVersion).get(data) depAuto <- (dep.project / autoScalaLibrary).get(data) - yield depCross match - case b: CrossVersion.Binary - if depAuto && VirtualAxis.isScala2Scala3Sandwich(sbv, depSBV) => - depProjId - .withCrossVersion(CrossVersion.constant(b.prefix + depSBV)) - .withConfigurations(dep.configuration) - .withExplicitArtifacts(Vector.empty) - case b: CrossVersion.Binary if sbv != depSBV => - depProjId - .withCrossVersion(CrossVersion.constant(b.prefix + depSBV + b.suffix)) - .withConfigurations(dep.configuration) - .withExplicitArtifacts(Vector.empty) - case f: CrossVersion.Full if sbv != depSBV => - val cross = (dep.project / scalaVersion) - .get(data) - .map(sv => CrossVersion.constant(f.prefix + sv + f.suffix)) - .getOrElse(depProjId.crossVersion) - depProjId - .withCrossVersion(cross) - .withConfigurations(dep.configuration) - .withExplicitArtifacts(Vector.empty) - // For3Use2_13/For2_13Use3 publish under compat suffix (e.g. bar_2.13 on Scala 3), - // not raw depSBV; sandwich case uses constant(depSBV) so would request wrong artifact. - case c: sbt.librarymanagement.For3Use2_13 if sbv != depSBV => - val compat = - if (depSBV == "3" || depSBV.startsWith("3.0.0")) "2.13" - else depSBV - depProjId - .withCrossVersion(CrossVersion.constant(c.prefix + compat + c.suffix)) - .withConfigurations(dep.configuration) - .withExplicitArtifacts(Vector.empty) - case c: sbt.librarymanagement.For2_13Use3 if sbv != depSBV => - val compat = if (depSBV == "2.13") "3" else depSBV - depProjId - .withCrossVersion(CrossVersion.constant(c.prefix + compat + c.suffix)) - .withConfigurations(dep.configuration) - .withExplicitArtifacts(Vector.empty) - case _ => - depProjId.withConfigurations(dep.configuration).withExplicitArtifacts(Vector.empty) + yield + if !allow && sbv != depSBV then + val depCp = (dep.project / crossPaths).get(data).getOrElse(true) + if depCp then + val depSv = (dep.project / scalaVersion).get(data).getOrElse("") + if !ClasspathImpl.isAllowedScalaMismatch(sv, depSv) then + sys.error( + s"Scala version mismatch: ${ref.project} (Scala $sv) depends on ${dep.project.project} (Scala $depSv). " + + s"To allow this, set `ThisProject / allowMismatchScala := true`" + ) + depCross match + case b: CrossVersion.Binary + if depAuto && VirtualAxis.isScala2Scala3Sandwich(sbv, depSBV) => + depProjId + .withCrossVersion(CrossVersion.constant(b.prefix + depSBV)) + .withConfigurations(dep.configuration) + .withExplicitArtifacts(Vector.empty) + case b: CrossVersion.Binary if sbv != depSBV => + depProjId + .withCrossVersion(CrossVersion.constant(b.prefix + depSBV + b.suffix)) + .withConfigurations(dep.configuration) + .withExplicitArtifacts(Vector.empty) + case f: CrossVersion.Full if sbv != depSBV => + val cross = (dep.project / scalaVersion) + .get(data) + .map(sv => CrossVersion.constant(f.prefix + sv + f.suffix)) + .getOrElse(depProjId.crossVersion) + depProjId + .withCrossVersion(cross) + .withConfigurations(dep.configuration) + .withExplicitArtifacts(Vector.empty) + // For3Use2_13/For2_13Use3 publish under compat suffix (e.g. bar_2.13 on Scala 3), + // not raw depSBV; sandwich case uses constant(depSBV) so would request wrong artifact. + case c: sbt.librarymanagement.For3Use2_13 if sbv != depSBV => + val compat = + if (depSBV == "3" || depSBV.startsWith("3.0.0")) "2.13" + else depSBV + depProjId + .withCrossVersion(CrossVersion.constant(c.prefix + compat + c.suffix)) + .withConfigurations(dep.configuration) + .withExplicitArtifacts(Vector.empty) + case c: sbt.librarymanagement.For2_13Use3 if sbv != depSBV => + val compat = if (depSBV == "2.13") "3" else depSBV + depProjId + .withCrossVersion(CrossVersion.constant(c.prefix + compat + c.suffix)) + .withConfigurations(dep.configuration) + .withExplicitArtifacts(Vector.empty) + case _ => + depProjId.withConfigurations(dep.configuration).withExplicitArtifacts(Vector.empty) } private[sbt] def depMap: Initialize[Task[Map[ModuleRevisionId, ModuleDescriptor]]] = diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index bed83e0d5..74f169a24 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -666,6 +666,7 @@ object Keys { val autoScalaLibrary = settingKey[Boolean]("Adds a dependency on scala-library if true.").withRank(ASetting) val managedScalaInstance = settingKey[Boolean]("Automatically obtains Scala tools as managed dependencies if true.").withRank(BSetting) val allowUnsafeScalaLibUpgrade = settingKey[Boolean]("Allow the Scala library on the compilation classpath to be newer than the scalaVersion (see Scala SIP-51).").withRank(CSetting) + val allowMismatchScala = settingKey[Boolean]("When set to true, allow dependsOn to an arbitrary Scala version subproject without error.").withRank(CSetting) val sbtResolver = settingKey[Resolver]("Provides a resolver for obtaining sbt as a dependency.").withRank(BMinusSetting) val sbtResolvers = settingKey[Seq[Resolver]]("The external resolvers for sbt and plugin dependencies.").withRank(BMinusSetting) val sbtDependency = settingKey[ModuleID]("Provides a definition for declaring the current version of sbt.").withRank(BMinusSetting) diff --git a/main/src/main/scala/sbt/internal/ClasspathImpl.scala b/main/src/main/scala/sbt/internal/ClasspathImpl.scala index cbc621ce9..93831fa57 100644 --- a/main/src/main/scala/sbt/internal/ClasspathImpl.scala +++ b/main/src/main/scala/sbt/internal/ClasspathImpl.scala @@ -15,7 +15,7 @@ import sbt.nio.Keys.* import sbt.nio.file.{ Glob, RecursiveGlob } import sbt.Def.Initialize import sbt.internal.util.{ Attributed, Dag } -import sbt.librarymanagement.{ Configuration, TrackLevel } +import sbt.librarymanagement.{ Configuration, CrossVersion, TrackLevel } import sbt.librarymanagement.Configurations.names import sbt.SlashSyntax0.* import sbt.std.TaskExtra.* @@ -309,6 +309,14 @@ private[sbt] object ClasspathImpl { ): Task[Classpath] = getClasspath(unmanagedJars, dep, conf, data) + private[sbt] def isAllowedScalaMismatch(sv1: String, sv2: String): Boolean = + val pv1 = CrossVersion.partialVersion(sv1) + val pv2 = CrossVersion.partialVersion(sv2) + (pv1, pv2) match + case (Some((2, 13)), Some((3, minor))) => minor <= 7 + case (Some((3, minor)), Some((2, 13))) => minor <= 7 + case _ => false + def interDependencies[A]( projectRef: ProjectRef, deps: BuildDependencies, diff --git a/main/src/test/scala/sbt/internal/ClasspathImplTest.scala b/main/src/test/scala/sbt/internal/ClasspathImplTest.scala new file mode 100644 index 000000000..cdfdc76d0 --- /dev/null +++ b/main/src/test/scala/sbt/internal/ClasspathImplTest.scala @@ -0,0 +1,54 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt +package internal + +import hedgehog.* +import hedgehog.runner.* + +object ClasspathImplTest extends Properties: + override def tests: List[Test] = List( + example( + "isAllowedScalaMismatch: 2.13.x and 3.5.x is allowed", + Result.assert(ClasspathImpl.isAllowedScalaMismatch("2.13.16", "3.5.1")) + ), + example( + "isAllowedScalaMismatch: 3.5.x and 2.13.x is allowed (reverse)", + Result.assert(ClasspathImpl.isAllowedScalaMismatch("3.5.1", "2.13.16")) + ), + example( + "isAllowedScalaMismatch: 2.13.x and 3.0.0 is allowed", + Result.assert(ClasspathImpl.isAllowedScalaMismatch("2.13.12", "3.0.0")) + ), + example( + "isAllowedScalaMismatch: 2.13.x and 3.7.0 is allowed", + Result.assert(ClasspathImpl.isAllowedScalaMismatch("2.13.12", "3.7.0")) + ), + example( + "isAllowedScalaMismatch: 2.13.x and 3.8.0 is NOT allowed", + Result.assert(!ClasspathImpl.isAllowedScalaMismatch("2.13.12", "3.8.0")) + ), + example( + "isAllowedScalaMismatch: 2.13.x and 3.8.1 is NOT allowed", + Result.assert(!ClasspathImpl.isAllowedScalaMismatch("2.13.16", "3.8.1")) + ), + example( + "isAllowedScalaMismatch: 2.12.x and 3.5.x is NOT allowed", + Result.assert(!ClasspathImpl.isAllowedScalaMismatch("2.12.21", "3.5.1")) + ), + example( + "isAllowedScalaMismatch: same versions is NOT a mismatch", + Result.assert(!ClasspathImpl.isAllowedScalaMismatch("3.5.1", "3.5.1")) + ), + example( + "isAllowedScalaMismatch: 2.12.x and 2.13.x is NOT allowed", + Result.assert(!ClasspathImpl.isAllowedScalaMismatch("2.12.21", "2.13.16")) + ), + ) +end ClasspathImplTest diff --git a/sbt-app/src/sbt-test/dependency-management/i4847-inter-project-variant-scala/build.sbt b/sbt-app/src/sbt-test/dependency-management/i4847-inter-project-variant-scala/build.sbt index 8cb6631f8..5e5f23e6b 100644 --- a/sbt-app/src/sbt-test/dependency-management/i4847-inter-project-variant-scala/build.sbt +++ b/sbt-app/src/sbt-test/dependency-management/i4847-inter-project-variant-scala/build.sbt @@ -16,4 +16,5 @@ lazy val baz = project .settings( scalaVersion := "2.13.12", name := "baz", + allowMismatchScala := true, ) diff --git a/sbt-app/src/sbt-test/dependency-management/stdlib-3.8/build.sbt b/sbt-app/src/sbt-test/dependency-management/stdlib-3.8/build.sbt index 506325a3a..3fc1fa0fd 100644 --- a/sbt-app/src/sbt-test/dependency-management/stdlib-3.8/build.sbt +++ b/sbt-app/src/sbt-test/dependency-management/stdlib-3.8/build.sbt @@ -9,4 +9,5 @@ lazy val b = project .settings( scalaVersion := "2.13.10", libraryDependencies += "org.scala-lang" % "scala-reflect" % "2.13.10", + allowMismatchScala := true, ) diff --git a/sbt-app/src/sbt-test/project/allow-mismatch-scala/build.sbt b/sbt-app/src/sbt-test/project/allow-mismatch-scala/build.sbt new file mode 100644 index 000000000..d72b9bb64 --- /dev/null +++ b/sbt-app/src/sbt-test/project/allow-mismatch-scala/build.sbt @@ -0,0 +1,23 @@ +lazy val scala3 = project + .settings( + scalaVersion := "3.5.1", + ) + +lazy val scala213 = project + .settings( + scalaVersion := "2.13.16", + ) + .dependsOn(scala3) + +// Separate pair for testing allowMismatchScala override (avoids SIP-51 scala-library conflict) +lazy val dep212 = project + .settings( + scalaVersion := "2.12.21", + ) + +lazy val app213 = project + .settings( + scalaVersion := "2.13.16", + allowMismatchScala := true, + ) + .dependsOn(dep212) diff --git a/sbt-app/src/sbt-test/project/allow-mismatch-scala/test b/sbt-app/src/sbt-test/project/allow-mismatch-scala/test new file mode 100644 index 000000000..725b2c323 --- /dev/null +++ b/sbt-app/src/sbt-test/project/allow-mismatch-scala/test @@ -0,0 +1,9 @@ +# 2.13 depending on 3.5 (<=3.7) is allowed as a sandwich exception +> scala213/compile + +# Now change scala3 to 3.8.x to trigger the mismatch error +> set scala3 / scalaVersion := "3.8.1" +-> scala213/compile + +# allowMismatchScala := true bypasses the check (using 2.13/2.12 pair to avoid SIP-51) +> app213/compile diff --git a/sbt-app/src/sbt-test/project/src-scala-binary-version/build.sbt b/sbt-app/src/sbt-test/project/src-scala-binary-version/build.sbt index 2130c9902..7f55b5004 100644 --- a/sbt-app/src/sbt-test/project/src-scala-binary-version/build.sbt +++ b/sbt-app/src/sbt-test/project/src-scala-binary-version/build.sbt @@ -1,5 +1,5 @@ lazy val a = project.dependsOn(b) - .settings(scalaVersion := "2.9.3") + .settings(scalaVersion := "2.9.3", allowMismatchScala := true) lazy val b = RootProject(uri("b")) lazy val check = taskKey[Unit]("Checks the configured scalaBinaryVersion")