From 1d77704ea3c779f92f1862204fe38f59fd7c56d2 Mon Sep 17 00:00:00 2001 From: eugene yokota Date: Tue, 24 Mar 2026 00:39:25 -0400 Subject: [PATCH] [2.0.x] feat: Add dependencyMode setting to control classpath transitivity (#8960) (#8972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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> --- build.sbt | 6 ++ .../librarymanagement/DependencyMode.scala | 29 +++++++ main/src/main/scala/sbt/Defaults.scala | 63 ++++++++++----- main/src/main/scala/sbt/Keys.scala | 1 + .../scala/sbt/internal/ClasspathImpl.scala | 81 ++++++++++++++++++- sbt-app/src/main/scala/sbt/Import.scala | 2 + .../dependency-mode/build.sbt | 55 +++++++++++++ .../dependency-mode/test | 11 +++ 8 files changed, 227 insertions(+), 21 deletions(-) create mode 100644 lm-core/src/main/scala/sbt/librarymanagement/DependencyMode.scala create mode 100644 sbt-app/src/sbt-test/dependency-management/dependency-mode/build.sbt create mode 100644 sbt-app/src/sbt-test/dependency-management/dependency-mode/test diff --git a/build.sbt b/build.sbt index 0eb15a488..2138733dc 100644 --- a/build.sbt +++ b/build.sbt @@ -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_=" + ), ) } diff --git a/lm-core/src/main/scala/sbt/librarymanagement/DependencyMode.scala b/lm-core/src/main/scala/sbt/librarymanagement/DependencyMode.scala new file mode 100644 index 000000000..eeb329f72 --- /dev/null +++ b/lm-core/src/main/scala/sbt/librarymanagement/DependencyMode.scala @@ -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 diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 37aa710cb..bfd867aaf 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -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, diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index b8f015d00..e532c73d9 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -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.") diff --git a/main/src/main/scala/sbt/internal/ClasspathImpl.scala b/main/src/main/scala/sbt/internal/ClasspathImpl.scala index 93831fa57..d36ab3d71 100644 --- a/main/src/main/scala/sbt/internal/ClasspathImpl.scala +++ b/main/src/main/scala/sbt/internal/ClasspathImpl.scala @@ -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 + } diff --git a/sbt-app/src/main/scala/sbt/Import.scala b/sbt-app/src/main/scala/sbt/Import.scala index 92900d05a..228d08faa 100644 --- a/sbt-app/src/main/scala/sbt/Import.scala +++ b/sbt-app/src/main/scala/sbt/Import.scala @@ -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 diff --git a/sbt-app/src/sbt-test/dependency-management/dependency-mode/build.sbt b/sbt-app/src/sbt-test/dependency-management/dependency-mode/build.sbt new file mode 100644 index 000000000..47f3d4ae0 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/dependency-mode/build.sbt @@ -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") + }, +) diff --git a/sbt-app/src/sbt-test/dependency-management/dependency-mode/test b/sbt-app/src/sbt-test/dependency-management/dependency-mode/test new file mode 100644 index 000000000..72a443e33 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/dependency-mode/test @@ -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