From 0ecc58125641e190e2a0bfd4a00eea3e01f3a201 Mon Sep 17 00:00:00 2001 From: BrianHotopp Date: Thu, 21 May 2026 23:20:56 -0400 Subject: [PATCH] [2.x] fix: Apply dependencyMode filtering to internal project deps (#9250) Fixes #9009. dependencyMode := DependencyMode.PlusOne strips every internal project dependency from the compile classpath, even direct ones -- LibA/compile fails with Not found: CoreClass despite LibA.dependsOn(Core). Direct mode happens to work today, but only by accident (filterByDirectDeps matches against allDependencies, which includes projectDependencies -- the bug surface is one step removed). Root cause: ClasspathImpl.filterByPlusOne resolves the direct-dep key set from UpdateReport, which only contains externally resolved (LM) modules. Internal project refs never appear there, so resolvedDirectKeys is empty for internal entries; plusOneKeys is likewise empty; the final filter strips every internal classpath entry whose moduleIDStr identifies an internal project. Diagnosis on the issue by eureka0928. Co-authored-by: Claude Opus 4.7 (1M context) --- main/src/main/scala/sbt/Defaults.scala | 35 ++++++--- .../scala/sbt/internal/ClasspathImpl.scala | 49 ++++++++++++ .../dependency-mode-internal/build.sbt | 78 +++++++++++++++++++ .../dependency-mode-internal/core/.gitkeep | 0 .../dependency-mode-internal/libA/.gitkeep | 0 .../dependency-mode-internal/libB/.gitkeep | 0 .../dependency-mode-internal/libC/.gitkeep | 0 .../dependency-mode-internal/test | 11 +++ 8 files changed, 162 insertions(+), 11 deletions(-) create mode 100644 sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/build.sbt create mode 100644 sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/core/.gitkeep create mode 100644 sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/libA/.gitkeep create mode 100644 sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/libB/.gitkeep create mode 100644 sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/libC/.gitkeep create mode 100644 sbt-app/src/sbt-test/dependency-management/dependency-mode-internal/test diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 14ea44658..4223c82ec 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -2737,16 +2737,24 @@ object Classpaths { dependencyMode.value match case DependencyMode.Transitive => Def.task { dependencyClasspath.value } - case _ => + case mode => Def.task { - ClasspathImpl.filterByDependencyMode( - dependencyMode.value, + val internalFiltered = ClasspathImpl.filterInternalByMode( + mode, + thisProjectRef.value, + settingsData.value, + buildDependencies.value, + internalDependencyClasspath.value, + ) + val externalFiltered = ClasspathImpl.filterByDependencyMode( + mode, allDependencies.value, projectID.value, classpathConfiguration.value, updateFull.value, - dependencyClasspath.value, + externalDependencyClasspath.value, ) + internalFiltered ++ externalFiltered } }) .value, @@ -2809,18 +2817,23 @@ object Classpaths { 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, + val mode = dependencyMode.value + val internalFiltered = ClasspathImpl.filterInternalByMode( + mode, + thisProjectRef.value, + settingsData.value, + buildDependencies.value, + internalDependencyPicklePath.value, + ) + val externalFiltered = ClasspathImpl.filterByDependencyMode( + mode, allDependencies.value, projectID.value, classpathConfiguration.value, updateFull.value, - cp, + externalDependencyClasspath.value, ) + internalFiltered ++ externalFiltered } else { filteredDependencyClasspath.value } diff --git a/main/src/main/scala/sbt/internal/ClasspathImpl.scala b/main/src/main/scala/sbt/internal/ClasspathImpl.scala index 094453293..9bcdf3a1a 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