[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
This commit is contained in:
Dream 2026-03-29 23:13:06 -04:00 committed by GitHub
parent 9936f9c07c
commit 3dd7f939cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 110 additions and 42 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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