mirror of https://github.com/sbt/sbt.git
[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) <noreply@anthropic.com>
This commit is contained in:
parent
f934219029
commit
0ecc581256
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue