[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>
This commit is contained in:
eugene yokota 2026-03-01 05:27:40 -05:00 committed by GitHub
parent d4d5e72961
commit bf33f51d10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 151 additions and 41 deletions

View File

@ -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]]] =

View File

@ -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)

View File

@ -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,

View File

@ -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

View File

@ -16,4 +16,5 @@ lazy val baz = project
.settings(
scalaVersion := "2.13.12",
name := "baz",
allowMismatchScala := true,
)

View File

@ -9,4 +9,5 @@ lazy val b = project
.settings(
scalaVersion := "2.13.10",
libraryDependencies += "org.scala-lang" % "scala-reflect" % "2.13.10",
allowMismatchScala := true,
)

View File

@ -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)

View File

@ -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

View File

@ -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")