From 3dd7f939cfce89d8f62d9f237e262c21e2a66fb6 Mon Sep 17 00:00:00 2001 From: Dream <42954461+eureka928@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:13:06 -0400 Subject: [PATCH] [2.x] fix: Scope dependencyMode filtering to compilation only (#8990) **Problem** When dependencyMode := Direct is set, the filtering was applied at the managedClasspath level, which removed transitive dependencies from all downstream classpaths including Test / dependencyClasspath. This caused runtime test failures because transitive deps like hamcrest-core (pulled in by junit) were missing. **Solution** Move the dependencyMode filtering from managedClasspath to a new filteredDependencyClasspath task, and wire dependencyPicklePath (the classpath used by the compiler) to use it. Runtime classpaths like dependencyClasspath and fullClasspath remain unfiltered, preserving all transitive dependencies for test execution. Fixes #8989 --- main/src/main/scala/sbt/Defaults.scala | 39 +++++++------ main/src/main/scala/sbt/Keys.scala | 1 + .../scala/sbt/internal/ClasspathImpl.scala | 18 ++++++ .../dependency-mode-test-classpath/build.sbt | 21 +++++++ .../src/test/java/com/example/HelloTest.java | 11 ++++ .../dependency-mode-test-classpath/test | 5 ++ .../dependency-mode/build.sbt | 57 +++++++++++-------- 7 files changed, 110 insertions(+), 42 deletions(-) create mode 100644 sbt-app/src/sbt-test/dependency-management/dependency-mode-test-classpath/build.sbt create mode 100644 sbt-app/src/sbt-test/dependency-management/dependency-mode-test-classpath/src/test/java/com/example/HelloTest.java create mode 100644 sbt-app/src/sbt-test/dependency-management/dependency-mode-test-classpath/test diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 1e98ff0ce..bc608662f 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -2662,26 +2662,21 @@ object Classpaths { ClasspathImpl.internalDependencyClasspathTask.value ), unmanagedClasspath := Def.uncached(ClasspathImpl.unmanagedDependenciesTask.value), - managedClasspath := Def + managedClasspath := Def.uncached(managedClasspathTask).value, + filteredDependencyClasspath := Def .uncached(Def.taskDyn { - val mode = dependencyMode.value - mode match - case DependencyMode.Transitive => managedClasspathTask - case DependencyMode.Direct => + dependencyMode.value match + case DependencyMode.Transitive => + Def.task { dependencyClasspath.value } + case _ => 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( + ClasspathImpl.filterByDependencyMode( + dependencyMode.value, allDependencies.value, projectID.value, - cpConfig, + classpathConfiguration.value, updateFull.value, - mjars, + dependencyClasspath.value, ) } }) @@ -2742,15 +2737,23 @@ object Classpaths { }, // Note: invoking this task from shell would block indefinitely because it will // wait for the upstream compilation to start. - dependencyPicklePath := { + dependencyPicklePath := Def.uncached { // This is a conditional task. Do not refactor. if (incOptions.value.pipelining) { - concat( + val cp = concat( internalDependencyPicklePath, externalDependencyClasspath, ).value + ClasspathImpl.filterByDependencyMode( + dependencyMode.value, + allDependencies.value, + projectID.value, + classpathConfiguration.value, + updateFull.value, + cp, + ) } else { - dependencyClasspath.value + filteredDependencyClasspath.value } }, internalDependencyPicklePath := ClasspathImpl.internalDependencyPicklePathTask.value, diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 539e529a2..a1658b56b 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -437,6 +437,7 @@ object Keys { val internalDependencyClasspath = taskKey[Classpath]("The internal (inter-project) classpath.").withRank(CTask) val externalDependencyClasspath = taskKey[Classpath]("The classpath consisting of library dependencies, both managed and unmanaged.").withRank(BMinusTask) val dependencyClasspath = taskKey[Classpath]("The classpath consisting of internal and external, managed and unmanaged dependencies.").withRank(BPlusTask) + val filteredDependencyClasspath = taskKey[Classpath]("The dependency classpath filtered by dependencyMode. Only affects compilation, not runtime classpaths.").withRank(BMinusTask) 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) diff --git a/main/src/main/scala/sbt/internal/ClasspathImpl.scala b/main/src/main/scala/sbt/internal/ClasspathImpl.scala index d36ab3d71..3ed91658d 100644 --- a/main/src/main/scala/sbt/internal/ClasspathImpl.scala +++ b/main/src/main/scala/sbt/internal/ClasspathImpl.scala @@ -19,6 +19,7 @@ import sbt.librarymanagement.{ ConfigRef, Configuration, CrossVersion, + DependencyMode, ModuleID, ScalaArtifacts, TrackLevel, @@ -541,4 +542,21 @@ private[sbt] object ClasspathImpl { isScalaLibraryModule(mid) case None => true + /** + * Apply dependencyMode filtering to a classpath. Entries without moduleIDStr metadata + * (e.g. internal project outputs) pass through unchanged. + */ + def filterByDependencyMode( + mode: DependencyMode, + directDeps: Seq[ModuleID], + projectId: ModuleID, + config: Configuration, + fullReport: UpdateReport, + cp: Classpath, + ): Classpath = + mode match + case DependencyMode.Transitive => cp + case DependencyMode.Direct => filterByDirectDeps(directDeps, cp) + case DependencyMode.PlusOne => filterByPlusOne(directDeps, projectId, config, fullReport, cp) + } diff --git a/sbt-app/src/sbt-test/dependency-management/dependency-mode-test-classpath/build.sbt b/sbt-app/src/sbt-test/dependency-management/dependency-mode-test-classpath/build.sbt new file mode 100644 index 000000000..bc9c43787 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/dependency-mode-test-classpath/build.sbt @@ -0,0 +1,21 @@ +import sbt.librarymanagement.DependencyMode.Direct + +autoScalaLibrary := false +libraryDependencies ++= Seq( + "junit" % "junit" % "4.13.2" % Test, + "com.github.sbt" % "junit-interface" % "0.13.2" % Test, +) +dependencyMode := Direct + +lazy val checkTestClasspath = taskKey[Unit]("check that Test classpath includes transitive deps") + +checkTestClasspath := { + val cp = (Test / dependencyClasspath).value.map(_.data.id) + // junit's transitive dep hamcrest-core must be on the test runtime classpath + assert(cp.exists(_.contains("hamcrest-core")), + s"Expected hamcrest-core in Test/dependencyClasspath, got: $cp") + assert(cp.exists(_.contains("junit")), + s"Expected junit in Test/dependencyClasspath, got: $cp") + assert(cp.exists(_.contains("junit-interface")), + s"Expected junit-interface in Test/dependencyClasspath, got: $cp") +} diff --git a/sbt-app/src/sbt-test/dependency-management/dependency-mode-test-classpath/src/test/java/com/example/HelloTest.java b/sbt-app/src/sbt-test/dependency-management/dependency-mode-test-classpath/src/test/java/com/example/HelloTest.java new file mode 100644 index 000000000..0752da780 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/dependency-mode-test-classpath/src/test/java/com/example/HelloTest.java @@ -0,0 +1,11 @@ +package com.example; + +import org.junit.Test; +import static org.junit.Assert.assertEquals; + +public class HelloTest { + @Test + public void testSum() { + assertEquals(1 + 1, 2); + } +} diff --git a/sbt-app/src/sbt-test/dependency-management/dependency-mode-test-classpath/test b/sbt-app/src/sbt-test/dependency-management/dependency-mode-test-classpath/test new file mode 100644 index 000000000..684b3464a --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/dependency-mode-test-classpath/test @@ -0,0 +1,5 @@ +# Verify Test classpath includes transitive deps even with Direct mode +> checkTestClasspath + +# Verify tests can actually run (hamcrest-core needed at runtime) +> test 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 index 47f3d4ae0..f0363639a 100644 --- a/sbt-app/src/sbt-test/dependency-management/dependency-mode/build.sbt +++ b/sbt-app/src/sbt-test/dependency-management/dependency-mode/build.sbt @@ -1,7 +1,7 @@ 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 checkDirectTest = taskKey[Unit]("check direct mode in Test config: runtime classpath is unfiltered") lazy val root = (project in file(".")).settings( scalaVersion := "3.7.4", @@ -19,37 +19,46 @@ lazy val root = (project in file(".")).settings( s"Expected guava in transitive mode, got: $cp") }, checkDirect := { + // managedClasspath is always unfiltered (transitive) 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") + s"Expected cats-core in managedClasspath, got: $cp") + assert(cp.exists(_.contains("cats-kernel")), + s"Expected cats-kernel in managedClasspath (always transitive), got: $cp") + // filteredDependencyClasspath respects dependencyMode + val filtered = (Compile / filteredDependencyClasspath).value.map(_.data.id) + assert(filtered.exists(_.contains("cats-core")), + s"Expected cats-core in filtered classpath, got: $filtered") + assert(filtered.exists(_.contains("guava")), + s"Expected guava in filtered classpath, got: $filtered") + assert(!filtered.exists(_.contains("cats-kernel")), + s"Expected no cats-kernel in filtered classpath (direct mode), got: $filtered") + assert(!filtered.exists(_.contains("failureaccess")), + s"Expected no failureaccess in filtered classpath (direct mode), got: $filtered") + assert(filtered.exists(n => n.contains("scala3-library") || n.contains("scala-library")), + s"Expected scala library in filtered classpath, got: $filtered") }, checkPlusOne := { - val cp = (Compile / managedClasspath).value.map(_.data.id) + val filtered = (Compile / filteredDependencyClasspath).value.map(_.data.id) // Direct deps should be present - assert(cp.exists(_.contains("cats-core")), - s"Expected cats-core in plusOne mode, got: $cp") + assert(filtered.exists(_.contains("cats-core")), + s"Expected cats-core in plusOne mode, got: $filtered") // 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") + assert(filtered.exists(_.contains("cats-kernel")), + s"Expected cats-kernel in plusOne mode (direct dep of cats-core), got: $filtered") }, checkDirectTest := { - val cp = (Test / managedClasspath).value.map(_.data.id) - // Direct deps should be present + // Test / dependencyClasspath should be unfiltered (includes transitive deps) + val cp = (Test / dependencyClasspath).value.map(_.data.id) 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") + s"Expected cats-core in Test dependencyClasspath, got: $cp") + assert(cp.exists(_.contains("cats-kernel")), + s"Expected cats-kernel in Test dependencyClasspath (transitive deps preserved), got: $cp") + // Test / filteredDependencyClasspath should be filtered + val filtered = (Test / filteredDependencyClasspath).value.map(_.data.id) + assert(filtered.exists(_.contains("cats-core")), + s"Expected cats-core in Test filtered classpath, got: $filtered") + assert(!filtered.exists(_.contains("cats-kernel")), + s"Expected no cats-kernel in Test filtered classpath (direct mode), got: $filtered") }, )