diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 14ea44658..fb73d8138 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -2737,16 +2737,38 @@ object Classpaths { dependencyMode.value match case DependencyMode.Transitive => Def.task { dependencyClasspath.value } - case _ => + case DependencyMode.Direct => Def.task { - ClasspathImpl.filterByDependencyMode( - dependencyMode.value, + val internalFiltered = ClasspathImpl.filterInternalByMode( + DependencyMode.Direct, + thisProjectRef.value, + settingsData.value, + buildDependencies.value, + internalDependencyClasspath.value, + ) + val externalFiltered = ClasspathImpl.filterByDirectDeps( + allDependencies.value, + externalDependencyClasspath.value, + ) + internalFiltered ++ externalFiltered + } + case DependencyMode.PlusOne => + Def.task { + val internalFiltered = ClasspathImpl.filterInternalByMode( + DependencyMode.PlusOne, + thisProjectRef.value, + settingsData.value, + buildDependencies.value, + internalDependencyClasspath.value, + ) + val externalFiltered = ClasspathImpl.filterByPlusOne( allDependencies.value, projectID.value, classpathConfiguration.value, updateFull.value, - dependencyClasspath.value, + externalDependencyClasspath.value, ) + internalFiltered ++ externalFiltered } }) .value, @@ -2807,23 +2829,46 @@ object Classpaths { // Note: invoking this task from shell would block indefinitely because it will // wait for the upstream compilation to start. dependencyPicklePath := Def.uncached { - // This is a conditional task. Do not refactor. - if (incOptions.value.pipelining) { - val cp = concat( - internalDependencyPicklePath, - externalDependencyClasspath, - ).value - ClasspathImpl.filterByDependencyMode( - dependencyMode.value, - allDependencies.value, - projectID.value, - classpathConfiguration.value, - updateFull.value, - cp, - ) - } else { - filteredDependencyClasspath.value - } + Def.taskDyn { + (incOptions.value.pipelining, dependencyMode.value) match + case (false, _) => + Def.task { filteredDependencyClasspath.value } + case (true, DependencyMode.Transitive) => + Def.task { dependencyClasspath.value } + case (true, DependencyMode.Direct) => + Def.task { + val internalFiltered = ClasspathImpl.filterInternalByMode( + DependencyMode.Direct, + thisProjectRef.value, + settingsData.value, + buildDependencies.value, + internalDependencyClasspath.value, + ) + val externalFiltered = ClasspathImpl.filterByDirectDeps( + allDependencies.value, + externalDependencyClasspath.value, + ) + internalFiltered ++ externalFiltered + } + case (true, DependencyMode.PlusOne) => + Def.task { + val internalFiltered = ClasspathImpl.filterInternalByMode( + DependencyMode.PlusOne, + thisProjectRef.value, + settingsData.value, + buildDependencies.value, + internalDependencyClasspath.value, + ) + val externalFiltered = ClasspathImpl.filterByPlusOne( + allDependencies.value, + projectID.value, + classpathConfiguration.value, + updateFull.value, + externalDependencyClasspath.value, + ) + internalFiltered ++ externalFiltered + } + }.value }, internalDependencyPicklePath := ClasspathImpl.internalDependencyPicklePathTask.value, exportedPickles := ClasspathImpl.exportedPicklesTask.value, diff --git a/main/src/main/scala/sbt/internal/ClasspathImpl.scala b/main/src/main/scala/sbt/internal/ClasspathImpl.scala index 094453293..d5dbeeb0a 100644 --- a/main/src/main/scala/sbt/internal/ClasspathImpl.scala +++ b/main/src/main/scala/sbt/internal/ClasspathImpl.scala @@ -559,4 +559,53 @@ private[sbt] object ClasspathImpl { case DependencyMode.Direct => filterByDirectDeps(directDeps, cp) case DependencyMode.PlusOne => filterByPlusOne(directDeps, projectId, config, fullReport, cp) + /** + * Apply dependencyMode filtering to the *internal* classpath using the build's project graph. + * `UpdateReport` only contains externally resolved modules, so it cannot answer + * "is this internal project a direct dep of `projectRef`"; the `BuildDependencies` graph can. + * + * Direct -- entries from projects directly listed in `projectRef.dependsOn(...)`. + * PlusOne -- direct + one more hop along the project graph. + * Transitive -- unfiltered. + */ + def filterInternalByMode( + mode: DependencyMode, + projectRef: ProjectRef, + data: Def.Settings, + deps: BuildDependencies, + internalCp: Classpath, + ): Classpath = + mode match + case DependencyMode.Transitive => internalCp + case _ => + val allowed = allowedInternalKeys(mode, projectRef, data, deps) + internalCp.filter: entry => + entry.get(Keys.moduleIDStr) match + case Some(str) => + val mid = Classpaths.moduleIdJsonKeyFormat.read(str) + allowed.contains((mid.organization, mid.name)) + case None => true + + private def allowedInternalKeys( + mode: DependencyMode, + projectRef: ProjectRef, + data: Def.Settings, + deps: BuildDependencies, + ): Set[(String, String)] = + def directRefs(p: ProjectRef): Set[ProjectRef] = + deps + .classpath(p) + .collect: + case ClasspathDep.ResolvedClasspathDependency(dep, _) => dep + .toSet + val refs: Set[ProjectRef] = mode match + case DependencyMode.Direct => + directRefs(projectRef) + case DependencyMode.PlusOne => + val direct = directRefs(projectRef) + direct ++ direct.flatMap(directRefs) + case DependencyMode.Transitive => Set.empty + refs.flatMap: pr => + (pr / projectID).get(data).map(mid => (mid.organization, mid.name)) + } diff --git a/sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/build.sbt b/sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/build.sbt new file mode 100644 index 000000000..f837b60ea --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/build.sbt @@ -0,0 +1,78 @@ +// Regression test for sbt/sbt#9009. dependencyMode := Direct / PlusOne +// must apply to *internal* project dependencies as well, walking the +// project graph (UpdateReport only contains external/LM modules). +// +// Fixture: core <- libA <- libB <- libC (each depends on the previous). +// +// Direct on libA -- must include core +// Direct on libB -- must *not* include core (core is transitive) +// PlusOne on libB -- must include core (one hop through libA) +// PlusOne on libC -- must include libA (one hop through libB) +// must *not* include core (two hops away) + +ThisBuild / scalaVersion := "3.7.4" +ThisBuild / organization := "org.example" +ThisBuild / version := "0.1.0-SNAPSHOT" + +lazy val core = (project in file("core")).settings(name := "core") + +lazy val libA = (project in file("libA")) + .settings(name := "libA") + .dependsOn(core) + +lazy val libB = (project in file("libB")) + .settings(name := "libB") + .dependsOn(libA) + +lazy val libC = (project in file("libC")) + .settings(name := "libC") + .dependsOn(libB) + +def filteredIds(cp: Seq[Attributed[xsbti.HashedVirtualFileRef]]): Seq[String] = + cp.map(_.data.id) + +// Match against the lowercased jar filename (sbt lowercases project +// names when building artifact filenames, e.g. `liba_3-...jar`). +def assertIn(needle: String, haystack: Seq[String], label: String): Unit = + val n = needle.toLowerCase + assert( + haystack.exists(_.toLowerCase.contains(n)), + s"$label: expected `$needle` to appear in $haystack" + ) + +def assertNotIn(needle: String, haystack: Seq[String], label: String): Unit = + val n = needle.toLowerCase + assert( + !haystack.exists(_.toLowerCase.contains(n)), + s"$label: expected `$needle` NOT to appear in $haystack" + ) + +lazy val checkDirectLibA = taskKey[Unit]("Direct mode on libA includes core") +lazy val checkDirectLibB = taskKey[Unit]("Direct mode on libB excludes core (transitive)") +lazy val checkPlusOneLibB = taskKey[Unit]("PlusOne mode on libB includes core (one hop)") +lazy val checkPlusOneLibC = + taskKey[Unit]("PlusOne mode on libC includes libA (one hop) but not core (two hops)") + +libA / checkDirectLibA := { + val cp = filteredIds((libA / Compile / filteredDependencyClasspath).value) + assertIn("core", cp, "libA/Direct") +} + +libB / checkDirectLibB := { + val cp = filteredIds((libB / Compile / filteredDependencyClasspath).value) + assertIn("libA", cp, "libB/Direct") + assertNotIn("core", cp, "libB/Direct") +} + +libB / checkPlusOneLibB := { + val cp = filteredIds((libB / Compile / filteredDependencyClasspath).value) + assertIn("libA", cp, "libB/PlusOne") + assertIn("core", cp, "libB/PlusOne") +} + +libC / checkPlusOneLibC := { + val cp = filteredIds((libC / Compile / filteredDependencyClasspath).value) + assertIn("libB", cp, "libC/PlusOne") + assertIn("libA", cp, "libC/PlusOne") + assertNotIn("core", cp, "libC/PlusOne") +} diff --git a/sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/core/.gitkeep b/sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/core/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/libA/.gitkeep b/sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/libA/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/libB/.gitkeep b/sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/libB/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/libC/.gitkeep b/sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/libC/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/test b/sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/test new file mode 100644 index 000000000..b85aaf3ef --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/test @@ -0,0 +1,11 @@ +# Direct mode: only the immediate dependsOn project is on the filtered +# classpath. Transitive internal deps (core via libA) are stripped. +> set ThisBuild / dependencyMode := DependencyMode.Direct +> libA/checkDirectLibA +> libB/checkDirectLibB + +# PlusOne mode: direct + one more hop. libB sees core (one hop through +# libA); libC sees libA but not core (two hops). +> set ThisBuild / dependencyMode := DependencyMode.PlusOne +> libB/checkPlusOneLibB +> libC/checkPlusOneLibC