mirror of https://github.com/sbt/sbt.git
**Problem** sbt always includes all transitive dependencies on the classpath. This makes it easy to accidentally depend on transitive dependencies without declaring them, leading to fragile builds that break when a library changes its own dependencies. **Solution** Add a `dependencyMode` setting with three modes: - DependencyMode.Transitive (default) — current behavior, all transitive dependencies on the classpath - DependencyMode.Direct — only declared dependencies plus scala-library on the classpath - DependencyMode.PlusOne — declared dependencies plus their immediate transitive dependencies plus scala-library Fixes sbt/sbt#8942 Co-authored-by: Dream <42954461+eureka928@users.noreply.github.com>
This commit is contained in:
parent
3d224539a2
commit
1d77704ea3
|
|
@ -941,7 +941,13 @@ lazy val upperModules = (project in (file("internal") / "upper"))
|
|||
)
|
||||
|
||||
lazy val sbtIgnoredProblems = {
|
||||
import com.typesafe.tools.mima.core.*
|
||||
Vector(
|
||||
// Adding DependencyMode to Import trait (new abstract members)
|
||||
ProblemFilters.exclude[ReversedMissingMethodProblem]("sbt.Import.DependencyMode"),
|
||||
ProblemFilters.exclude[ReversedMissingMethodProblem](
|
||||
"sbt.Import.sbt$Import$_setter_$DependencyMode_="
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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.librarymanagement
|
||||
|
||||
/**
|
||||
* Controls which managed dependencies appear on the classpath.
|
||||
*
|
||||
* This setting is used to enforce explicit dependency declarations
|
||||
* by restricting the classpath to only include dependencies at a
|
||||
* specified depth level.
|
||||
*/
|
||||
enum DependencyMode:
|
||||
|
||||
/** All transitive dependencies are included (default, current behavior). */
|
||||
case Transitive
|
||||
|
||||
/** Only direct dependencies plus scala-library are included. */
|
||||
case Direct
|
||||
|
||||
/** Direct dependencies plus their immediate transitive dependencies plus scala-library. */
|
||||
case PlusOne
|
||||
|
||||
end DependencyMode
|
||||
|
|
@ -251,6 +251,7 @@ object Defaults extends BuildCommon {
|
|||
internalConfigurationMap :== Configurations.internalMap,
|
||||
credentials :== SysProp.sbtCredentialsEnv.toList,
|
||||
exportJars :== true,
|
||||
dependencyMode :== DependencyMode.Transitive,
|
||||
trackInternalDependencies :== TrackLevel.TrackAlways,
|
||||
exportToInternal :== TrackLevel.TrackAlways,
|
||||
retrieveManaged :== false,
|
||||
|
|
@ -2710,26 +2711,30 @@ object Classpaths {
|
|||
ClasspathImpl.internalDependencyClasspathTask.value
|
||||
),
|
||||
unmanagedClasspath := Def.uncached(ClasspathImpl.unmanagedDependenciesTask.value),
|
||||
managedClasspath := Def.uncached {
|
||||
val converter = fileConverter.value
|
||||
val isMeta = isMetaBuild.value
|
||||
val force = reresolveSbtArtifacts.value
|
||||
val app = appConfiguration.value
|
||||
def isJansiOrJLine(f: File) = f.getName.contains("jline") || f.getName.contains("jansi")
|
||||
val scalaInstanceJars = app.provider.scalaProvider.jars.filterNot(isJansiOrJLine)
|
||||
val sbtCp = (scalaInstanceJars ++ app.provider.mainClasspath)
|
||||
.map(_.toPath)
|
||||
.map(p => converter.toVirtualFile(p): HashedVirtualFileRef)
|
||||
.map(Attributed.blank)
|
||||
val mjars = managedJars(
|
||||
classpathConfiguration.value,
|
||||
classpathTypes.value,
|
||||
update.value,
|
||||
converter,
|
||||
)
|
||||
if isMeta && !force then (mjars ++ sbtCp).distinct
|
||||
else mjars
|
||||
},
|
||||
managedClasspath := Def
|
||||
.uncached(Def.taskDyn {
|
||||
val mode = dependencyMode.value
|
||||
mode match
|
||||
case DependencyMode.Transitive => managedClasspathTask
|
||||
case DependencyMode.Direct =>
|
||||
Def.task {
|
||||
val mjars = managedClasspathTask.value
|
||||
ClasspathImpl.filterByDirectDeps(allDependencies.value, mjars)
|
||||
}
|
||||
case DependencyMode.PlusOne =>
|
||||
Def.task {
|
||||
val mjars = managedClasspathTask.value
|
||||
val cpConfig = classpathConfiguration.value
|
||||
ClasspathImpl.filterByPlusOne(
|
||||
allDependencies.value,
|
||||
projectID.value,
|
||||
cpConfig,
|
||||
updateFull.value,
|
||||
mjars,
|
||||
)
|
||||
}
|
||||
})
|
||||
.value,
|
||||
exportedProducts := Def.uncached(
|
||||
ClasspathImpl.trackedExportedProducts(TrackLevel.TrackAlways).value
|
||||
),
|
||||
|
|
@ -4490,6 +4495,24 @@ object Classpaths {
|
|||
}
|
||||
.distinct
|
||||
|
||||
private lazy val managedClasspathTask: Initialize[Task[Classpath]] = Def.task {
|
||||
val converter = fileConverter.value
|
||||
val isMeta = isMetaBuild.value
|
||||
val force = reresolveSbtArtifacts.value
|
||||
val app = appConfiguration.value
|
||||
def isJansiOrJLine(f: File) = f.getName.contains("jline") || f.getName.contains("jansi")
|
||||
val scalaInstanceJars = app.provider.scalaProvider.jars.filterNot(isJansiOrJLine)
|
||||
val sbtCp = (scalaInstanceJars ++ app.provider.mainClasspath)
|
||||
.map(_.toPath)
|
||||
.map(p => converter.toVirtualFile(p): HashedVirtualFileRef)
|
||||
.map(Attributed.blank)
|
||||
val cpConfig = classpathConfiguration.value
|
||||
val up = update.value
|
||||
val mjars = managedJars(cpConfig, classpathTypes.value, up, converter)
|
||||
if isMeta && !force then (mjars ++ sbtCp).distinct
|
||||
else mjars
|
||||
}
|
||||
|
||||
def findUnmanagedJars(
|
||||
config: Configuration,
|
||||
base: File,
|
||||
|
|
|
|||
|
|
@ -443,6 +443,7 @@ object Keys {
|
|||
val dependencyPicklePath = taskKey[Classpath]("The classpath consisting of internal pickles and external, managed and unmanaged dependencies. This task is promise-blocked.")
|
||||
val internalDependencyPicklePath = taskKey[Classpath]("The internal (inter-project) pickles. This task is promise-blocked.")
|
||||
val fullClasspath = taskKey[Classpath]("The exported classpath, consisting of build products and unmanaged and managed, internal and external dependencies.").withRank(BPlusTask)
|
||||
val dependencyMode = settingKey[DependencyMode]("Controls which managed dependencies appear on the classpath: Transitive (all), Direct (only direct + scala-library), or PlusOne (direct + their immediate deps + scala-library).").withRank(BSetting)
|
||||
val trackInternalDependencies = settingKey[TrackLevel]("The level of tracking for the internal (inter-project) dependency.").withRank(BSetting)
|
||||
val exportToInternal = settingKey[TrackLevel]("The level of tracking for this project by the internal callers.").withRank(BSetting)
|
||||
val exportedProductJars = taskKey[Classpath]("Build products that go on the exported classpath as JARs.")
|
||||
|
|
|
|||
|
|
@ -15,7 +15,15 @@ import sbt.nio.Keys.*
|
|||
import sbt.nio.file.{ Glob, RecursiveGlob }
|
||||
import sbt.Def.Initialize
|
||||
import sbt.internal.util.{ Attributed, Dag }
|
||||
import sbt.librarymanagement.{ Configuration, CrossVersion, TrackLevel }
|
||||
import sbt.librarymanagement.{
|
||||
ConfigRef,
|
||||
Configuration,
|
||||
CrossVersion,
|
||||
ModuleID,
|
||||
ScalaArtifacts,
|
||||
TrackLevel,
|
||||
UpdateReport
|
||||
}
|
||||
import sbt.librarymanagement.Configurations.names
|
||||
import sbt.SlashSyntax0.*
|
||||
import sbt.std.TaskExtra.*
|
||||
|
|
@ -462,4 +470,75 @@ private[sbt] object ClasspathImpl {
|
|||
case _ => constant(Nil)
|
||||
}
|
||||
|
||||
// -- dependencyMode filtering --
|
||||
|
||||
private def isScalaLibraryModule(mid: ModuleID): Boolean =
|
||||
mid.organization == ScalaArtifacts.Organization &&
|
||||
(mid.name == ScalaArtifacts.LibraryID ||
|
||||
mid.name == ScalaArtifacts.Scala3LibraryID ||
|
||||
mid.name.startsWith(ScalaArtifacts.Scala3LibraryPrefix))
|
||||
|
||||
/** Build a lookup from org -> Set[baseName] for cross-version aware matching. */
|
||||
private def directDepIndex(
|
||||
directDeps: Seq[ModuleID],
|
||||
): Map[String, Set[String]] =
|
||||
directDeps.groupMap(_.organization)(_.name).map((k, v) => k -> v.toSet)
|
||||
|
||||
/** Check if a resolved module matches any direct dep, accounting for cross-version suffixes. */
|
||||
private def matchesDirectDep(
|
||||
mid: ModuleID,
|
||||
index: Map[String, Set[String]],
|
||||
): Boolean =
|
||||
index.get(mid.organization) match
|
||||
case None => false
|
||||
case Some(names) =>
|
||||
names.exists(n => mid.name == n || mid.name.startsWith(n + "_"))
|
||||
|
||||
def filterByDirectDeps(
|
||||
directDeps: Seq[ModuleID],
|
||||
jars: Classpath,
|
||||
): Classpath =
|
||||
val index = directDepIndex(directDeps)
|
||||
jars.filter: entry =>
|
||||
entry.get(Keys.moduleIDStr) match
|
||||
case Some(str) =>
|
||||
val mid = Classpaths.moduleIdJsonKeyFormat.read(str)
|
||||
matchesDirectDep(mid, index) || isScalaLibraryModule(mid)
|
||||
case None => true
|
||||
|
||||
def filterByPlusOne(
|
||||
directDeps: Seq[ModuleID],
|
||||
projectId: ModuleID,
|
||||
config: Configuration,
|
||||
fullReport: UpdateReport,
|
||||
jars: Classpath,
|
||||
): Classpath =
|
||||
val index = directDepIndex(directDeps)
|
||||
val rootKey = (projectId.organization, projectId.name)
|
||||
fullReport.configuration(ConfigRef(config.name)) match
|
||||
case None => jars
|
||||
case Some(configReport) =>
|
||||
val modules = configReport.modules
|
||||
// Callers use resolved names (e.g., cats-core_3).
|
||||
// Build the set of resolved direct dep keys from the full report.
|
||||
val resolvedDirectKeys: Set[(String, String)] = modules
|
||||
.filter(mr => matchesDirectDep(mr.module, index))
|
||||
.map(mr => (mr.module.organization, mr.module.name))
|
||||
.toSet
|
||||
val plusOneKeys: Set[(String, String)] = modules
|
||||
.filter: mr =>
|
||||
mr.callers.exists: c =>
|
||||
val ck = (c.caller.organization, c.caller.name)
|
||||
resolvedDirectKeys.contains(ck) || ck == rootKey
|
||||
.map(mr => (mr.module.organization, mr.module.name))
|
||||
.toSet
|
||||
val allowedKeys = resolvedDirectKeys ++ plusOneKeys
|
||||
jars.filter: entry =>
|
||||
entry.get(Keys.moduleIDStr) match
|
||||
case Some(str) =>
|
||||
val mid = Classpaths.moduleIdJsonKeyFormat.read(str)
|
||||
allowedKeys.contains((mid.organization, mid.name)) ||
|
||||
isScalaLibraryModule(mid)
|
||||
case None => true
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -273,6 +273,8 @@ trait Import {
|
|||
val CrossVersion = sbt.librarymanagement.CrossVersion
|
||||
type CrossVersion = sbt.librarymanagement.CrossVersion
|
||||
val DefaultMavenRepository = sbt.librarymanagement.Resolver.DefaultMavenRepository
|
||||
type DependencyMode = sbt.librarymanagement.DependencyMode
|
||||
val DependencyMode = sbt.librarymanagement.DependencyMode
|
||||
val Developer = sbt.librarymanagement.Developer
|
||||
type Developer = sbt.librarymanagement.Developer
|
||||
val Disabled = sbt.librarymanagement.Disabled
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
lazy val checkDirect = taskKey[Unit]("check direct dependency mode")
|
||||
lazy val checkPlusOne = taskKey[Unit]("check plusOne dependency mode")
|
||||
lazy val checkTransitive = taskKey[Unit]("check transitive dependency mode")
|
||||
lazy val checkDirectTest = taskKey[Unit]("check direct mode applies to Test config")
|
||||
|
||||
lazy val root = (project in file(".")).settings(
|
||||
scalaVersion := "3.7.4",
|
||||
// cats-core has transitive dep on cats-kernel
|
||||
libraryDependencies += "org.typelevel" %% "cats-core" % "2.12.0",
|
||||
// A Java library with transitive deps (guava pulls in failureaccess, etc.)
|
||||
libraryDependencies += "com.google.guava" % "guava" % "33.4.0-jre",
|
||||
checkTransitive := {
|
||||
val cp = (Compile / managedClasspath).value.map(_.data.id)
|
||||
assert(cp.exists(_.contains("cats-core")),
|
||||
s"Expected cats-core in transitive mode, got: $cp")
|
||||
assert(cp.exists(_.contains("cats-kernel")),
|
||||
s"Expected cats-kernel in transitive mode, got: $cp")
|
||||
assert(cp.exists(_.contains("guava")),
|
||||
s"Expected guava in transitive mode, got: $cp")
|
||||
},
|
||||
checkDirect := {
|
||||
val cp = (Compile / managedClasspath).value.map(_.data.id)
|
||||
// Direct deps should be present
|
||||
assert(cp.exists(_.contains("cats-core")),
|
||||
s"Expected cats-core in direct mode, got: $cp")
|
||||
assert(cp.exists(_.contains("guava")),
|
||||
s"Expected guava in direct mode, got: $cp")
|
||||
// Transitive deps should be absent
|
||||
assert(!cp.exists(_.contains("cats-kernel")),
|
||||
s"Expected no cats-kernel in direct mode, got: $cp")
|
||||
assert(!cp.exists(_.contains("failureaccess")),
|
||||
s"Expected no failureaccess in direct mode, got: $cp")
|
||||
// scala-library always present
|
||||
assert(cp.exists(n => n.contains("scala3-library") || n.contains("scala-library")),
|
||||
s"Expected scala library in direct mode, got: $cp")
|
||||
},
|
||||
checkPlusOne := {
|
||||
val cp = (Compile / managedClasspath).value.map(_.data.id)
|
||||
// Direct deps should be present
|
||||
assert(cp.exists(_.contains("cats-core")),
|
||||
s"Expected cats-core in plusOne mode, got: $cp")
|
||||
// Immediate transitive of cats-core should be present
|
||||
assert(cp.exists(_.contains("cats-kernel")),
|
||||
s"Expected cats-kernel in plusOne mode (direct dep of cats-core), got: $cp")
|
||||
},
|
||||
checkDirectTest := {
|
||||
val cp = (Test / managedClasspath).value.map(_.data.id)
|
||||
// Direct deps should be present
|
||||
assert(cp.exists(_.contains("cats-core")),
|
||||
s"Expected cats-core in direct mode Test config, got: $cp")
|
||||
// Transitive deps should be absent
|
||||
assert(!cp.exists(_.contains("cats-kernel")),
|
||||
s"Expected no cats-kernel in direct mode Test config, got: $cp")
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# Default mode is transitive
|
||||
> checkTransitive
|
||||
|
||||
# Direct mode: only declared deps + scala-library
|
||||
> set dependencyMode := DependencyMode.Direct
|
||||
> checkDirect
|
||||
> checkDirectTest
|
||||
|
||||
# PlusOne mode: direct deps + their immediate transitive deps
|
||||
> set dependencyMode := DependencyMode.PlusOne
|
||||
> checkPlusOne
|
||||
Loading…
Reference in New Issue