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