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 = {
|
lazy val sbtIgnoredProblems = {
|
||||||
|
import com.typesafe.tools.mima.core.*
|
||||||
Vector(
|
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,
|
internalConfigurationMap :== Configurations.internalMap,
|
||||||
credentials :== SysProp.sbtCredentialsEnv.toList,
|
credentials :== SysProp.sbtCredentialsEnv.toList,
|
||||||
exportJars :== true,
|
exportJars :== true,
|
||||||
|
dependencyMode :== DependencyMode.Transitive,
|
||||||
trackInternalDependencies :== TrackLevel.TrackAlways,
|
trackInternalDependencies :== TrackLevel.TrackAlways,
|
||||||
exportToInternal :== TrackLevel.TrackAlways,
|
exportToInternal :== TrackLevel.TrackAlways,
|
||||||
retrieveManaged :== false,
|
retrieveManaged :== false,
|
||||||
|
|
@ -2710,26 +2711,30 @@ object Classpaths {
|
||||||
ClasspathImpl.internalDependencyClasspathTask.value
|
ClasspathImpl.internalDependencyClasspathTask.value
|
||||||
),
|
),
|
||||||
unmanagedClasspath := Def.uncached(ClasspathImpl.unmanagedDependenciesTask.value),
|
unmanagedClasspath := Def.uncached(ClasspathImpl.unmanagedDependenciesTask.value),
|
||||||
managedClasspath := Def.uncached {
|
managedClasspath := Def
|
||||||
val converter = fileConverter.value
|
.uncached(Def.taskDyn {
|
||||||
val isMeta = isMetaBuild.value
|
val mode = dependencyMode.value
|
||||||
val force = reresolveSbtArtifacts.value
|
mode match
|
||||||
val app = appConfiguration.value
|
case DependencyMode.Transitive => managedClasspathTask
|
||||||
def isJansiOrJLine(f: File) = f.getName.contains("jline") || f.getName.contains("jansi")
|
case DependencyMode.Direct =>
|
||||||
val scalaInstanceJars = app.provider.scalaProvider.jars.filterNot(isJansiOrJLine)
|
Def.task {
|
||||||
val sbtCp = (scalaInstanceJars ++ app.provider.mainClasspath)
|
val mjars = managedClasspathTask.value
|
||||||
.map(_.toPath)
|
ClasspathImpl.filterByDirectDeps(allDependencies.value, mjars)
|
||||||
.map(p => converter.toVirtualFile(p): HashedVirtualFileRef)
|
}
|
||||||
.map(Attributed.blank)
|
case DependencyMode.PlusOne =>
|
||||||
val mjars = managedJars(
|
Def.task {
|
||||||
classpathConfiguration.value,
|
val mjars = managedClasspathTask.value
|
||||||
classpathTypes.value,
|
val cpConfig = classpathConfiguration.value
|
||||||
update.value,
|
ClasspathImpl.filterByPlusOne(
|
||||||
converter,
|
allDependencies.value,
|
||||||
)
|
projectID.value,
|
||||||
if isMeta && !force then (mjars ++ sbtCp).distinct
|
cpConfig,
|
||||||
else mjars
|
updateFull.value,
|
||||||
},
|
mjars,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.value,
|
||||||
exportedProducts := Def.uncached(
|
exportedProducts := Def.uncached(
|
||||||
ClasspathImpl.trackedExportedProducts(TrackLevel.TrackAlways).value
|
ClasspathImpl.trackedExportedProducts(TrackLevel.TrackAlways).value
|
||||||
),
|
),
|
||||||
|
|
@ -4490,6 +4495,24 @@ object Classpaths {
|
||||||
}
|
}
|
||||||
.distinct
|
.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(
|
def findUnmanagedJars(
|
||||||
config: Configuration,
|
config: Configuration,
|
||||||
base: File,
|
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 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 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 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 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 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.")
|
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.nio.file.{ Glob, RecursiveGlob }
|
||||||
import sbt.Def.Initialize
|
import sbt.Def.Initialize
|
||||||
import sbt.internal.util.{ Attributed, Dag }
|
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.librarymanagement.Configurations.names
|
||||||
import sbt.SlashSyntax0.*
|
import sbt.SlashSyntax0.*
|
||||||
import sbt.std.TaskExtra.*
|
import sbt.std.TaskExtra.*
|
||||||
|
|
@ -462,4 +470,75 @@ private[sbt] object ClasspathImpl {
|
||||||
case _ => constant(Nil)
|
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
|
val CrossVersion = sbt.librarymanagement.CrossVersion
|
||||||
type CrossVersion = sbt.librarymanagement.CrossVersion
|
type CrossVersion = sbt.librarymanagement.CrossVersion
|
||||||
val DefaultMavenRepository = sbt.librarymanagement.Resolver.DefaultMavenRepository
|
val DefaultMavenRepository = sbt.librarymanagement.Resolver.DefaultMavenRepository
|
||||||
|
type DependencyMode = sbt.librarymanagement.DependencyMode
|
||||||
|
val DependencyMode = sbt.librarymanagement.DependencyMode
|
||||||
val Developer = sbt.librarymanagement.Developer
|
val Developer = sbt.librarymanagement.Developer
|
||||||
type Developer = sbt.librarymanagement.Developer
|
type Developer = sbt.librarymanagement.Developer
|
||||||
val Disabled = sbt.librarymanagement.Disabled
|
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