[2.0.x] feat: Add dependencyMode setting to control classpath transitivity (#8960) (#8972)

**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:
eugene yokota 2026-03-24 00:39:25 -04:00 committed by GitHub
parent 3d224539a2
commit 1d77704ea3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 227 additions and 21 deletions

View File

@ -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_="
),
)
}

View File

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

View File

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

View 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.")

View File

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

View File

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

View File

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

View File

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