[2.x] fix: make ScopeFilter .all ordering deterministic and preserve project filter order (#8861)

Fixes nondeterministic ordering in `ScopeFilter`-backed `.all(...)` aggregation.

`SettingKeyAll.all` and `TaskKeyAll.all` previously iterated a `Set[Scope]` using `toSeq`, which made returned values order-dependent on hash iteration. This PR keeps `.all(...)` deterministic and preserves explicit project ordering when a filter provides it (for example, `inProjects(c, a, b)`).
This commit is contained in:
bitloi 2026-03-02 17:42:24 -05:00 committed by GitHub
parent 2d583bfe65
commit dfa5e31571
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 244 additions and 5 deletions

View File

@ -9,11 +9,11 @@
package sbt
import sbt.internal.{ Load, LoadedBuildUnit }
import sbt.internal.util.{ AttributeKey, Dag }
import sbt.internal.util.{ AttributeKey, AttributeMap, Dag }
import sbt.librarymanagement.{ ConfigRef, Configuration }
import sbt.internal.util.Types.const
import Def.Initialize
import sbt.ScopeAxis.{ Select, Zero }
import sbt.ScopeAxis.{ Select, This, Zero }
import java.net.URI
sealed abstract class ScopeFilter { self =>
@ -21,6 +21,12 @@ sealed abstract class ScopeFilter { self =>
/** Implements this filter. */
private[ScopeFilter] def apply(data: ScopeFilter.Data): Set[Scope]
/**
* Optional ordering hint used by `.all(...)` when the filter can provide a stable
* project ordering.
*/
private[ScopeFilter] def scopeOrdering(data: ScopeFilter.Data): Scope => Option[Int] = _ => None
/** Constructs a filter that selects values that match this filter but not `other`. */
def --(other: ScopeFilter): ScopeFilter = this && -other
@ -84,6 +90,10 @@ object ScopeFilter {
} yield scope
res.toSet
override private[ScopeFilter] def scopeOrdering(data: Data): Scope => Option[Int] =
val projectOrdering = projects.ordering(data)
scope => projectOrdering(scope.project)
def debug(delegate: ScopeFilter): ScopeFilter =
new ScopeFilter:
def apply(data: Data): Set[Scope] =
@ -97,7 +107,8 @@ object ScopeFilter {
* static inspections will not show them.
*/
def all(sfilter: => ScopeFilter): Initialize[Seq[A]] = Def.flatMap(getData) { data =>
sfilter(data).toSeq.map(s => Project.inScope(s, i)).join
val filter = sfilter
orderedScopes(filter(data), filter.scopeOrdering(data)).map(s => Project.inScope(s, i)).join
}
final class TaskKeyAll[A] private[sbt] (i: Initialize[Task[A]]):
@ -107,9 +118,103 @@ object ScopeFilter {
*/
def all(sfilter: => ScopeFilter): Initialize[Task[Seq[A]]] = Def.flatMap(getData) { data =>
import std.TaskExtra.*
sfilter(data).toSeq.map(s => Project.inScope(s, i)).join(_.join)
val filter = sfilter
orderedScopes(filter(data), filter.scopeOrdering(data))
.map(s => Project.inScope(s, i))
.join(
_.join
)
}
private type ScopeAxisOrderingKey = (Int, String)
private type ScopeOrderingKey = (
ScopeAxisOrderingKey,
ScopeAxisOrderingKey,
ScopeAxisOrderingKey,
ScopeAxisOrderingKey
)
private def orderedScopes(
scopes: Iterable[Scope],
scopeOrdering: Scope => Option[Int]
): Seq[Scope] =
scopes.toSeq.sortBy { scope =>
(
scopeOrdering(scope) match
case Some(order) => (0, order)
case None => (1, Int.MaxValue),
scopeOrderingKey(scope)
)
}
private[sbt] def orderedScopesForTests(
scopes: Iterable[Scope],
scopeOrdering: Scope => Option[Int]
): Seq[Scope] = orderedScopes(scopes, scopeOrdering)
private def scopeOrderingKey(scope: Scope): ScopeOrderingKey =
(
scopeAxisOrderingKey(scope.project)(referenceOrderingKey),
scopeAxisOrderingKey(scope.config)(_.name),
scopeAxisOrderingKey(scope.task)(attributeKeyOrderingKey),
scopeAxisOrderingKey(scope.extra)(attributeMapOrderingKey)
)
private def scopeAxisOrderingKey[A](
axis: ScopeAxis[A]
)(selectedKey: A => String): ScopeAxisOrderingKey =
axis match
case Select(selected) => (0, selectedKey(selected))
case This => (1, "")
case Zero => (2, "")
private def referenceOrderingKey(reference: Reference): String =
reference match
case ThisBuild => "0"
case ThisProject => "1"
case LocalAggregate => "2"
case LocalRootProject => "3"
case LocalProject(id) => s"4:$id"
case BuildRef(build) => s"5:${uriOrderingKey(build)}"
case RootProject(build) => s"6:${uriOrderingKey(build)}"
case ProjectRef(build, project) =>
s"7:${uriOrderingKey(build)}:$project"
private def uriOrderingKey(uri: URI): String = uri.normalize.toASCIIString
private def attributeKeyOrderingKey(key: AttributeKey[?]): String =
s"${key.label}:${key.tag}:rank=${key.rank}"
private def attributeMapOrderingKey(map: AttributeMap): String =
map.entries.toSeq
.map(entry =>
val key = attributeKeyOrderingKey(entry.key)
val value = attributeValueOrderingKey(entry.value)
s"$key=$value"
)
.sorted
.mkString("[", ",", "]")
private def attributeValueOrderingKey(value: Any): String =
value match
case null => "null"
case m: collection.Map[?, ?] =>
m.iterator
.map((k, v) => s"${attributeValueOrderingKey(k)}->${attributeValueOrderingKey(v)}")
.toSeq
.sorted
.mkString("Map(", ",", ")")
case i: Iterable[?] =>
i.iterator
.map(attributeValueOrderingKey)
.mkString("Iterable(", ",", ")")
case p: Product =>
p.productIterator
.map(attributeValueOrderingKey)
.mkString(s"Product(${p.productPrefix}|", ",", ")")
case other =>
s"${other.getClass.getName}(${other.toString})"
private[sbt] val Make = new Make {}
trait Make {
@ -283,7 +388,20 @@ object ScopeFilter {
inResolvedProjects(data => projects.map(data.resolve))
private def inResolvedProjects(projects: Data => Seq[ProjectRef]): ProjectFilter =
selectAxis(data => projects(data).toSet)
new AxisFilter[Reference]:
private[sbt] def apply(data: Data): ScopeAxis[Reference] => Boolean =
val selected = projects(data).toSet
_ match
case Select(ref: ProjectRef) => selected(ref)
case _ => false
override private[ScopeFilter] def ordering(
data: Data
): ScopeAxis[Reference] => Option[Int] =
val index = projects(data).zipWithIndex.toMap
_ match
case Select(ref: ProjectRef) => index.get(ref)
case _ => None
private def zeroAxis[T]: AxisFilter[T] = new AxisFilter[T] {
private[sbt] def apply(data: Data): ScopeAxis[T] => Boolean = _ == Zero
@ -305,6 +423,11 @@ object ScopeFilter {
/** Implements this filter. */
private[ScopeFilter] def apply(data: Data): ScopeAxis[In] => Boolean
/**
* Optional ordering hint for axes selected by this filter.
*/
private[ScopeFilter] def ordering(data: Data): ScopeAxis[In] => Option[Int] = _ => None
/** Constructs a filter that selects values that match this filter but not `other`. */
def --(other: AxisFilter[In]): AxisFilter[In] = this && -other

View File

@ -0,0 +1,67 @@
/*
* sbt
* Copyright 2023, Scala center
* Copyright 2011 - 2022, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt
import sbt.ScopeAxis.{ Select, Zero }
import hedgehog.*
import hedgehog.runner.*
import java.net.URI
object ScopeFilterOrderingSpec extends Properties {
private val buildUri = new URI("file:///scope-filter-ordering/")
override def tests: List[Test] =
List(
property(
"all ordering is deterministic without ordering hints",
projectIdsGen.forAll.map { projectIds =>
val scopes = projectIds.map(toProjectRef).flatMap(projectScopes)
val noHint: Scope => Option[Int] = _ => None
val fromSet = ScopeFilter.orderedScopesForTests(scopes.toSet, noHint)
val fromReverse = ScopeFilter.orderedScopesForTests(scopes.reverse, noHint)
fromSet ==== fromReverse
}
),
property(
"all ordering preserves explicit project ordering hints",
projectIdsGen.forAll.map { projectIds =>
val orderedProjects = projectIds.map(toProjectRef)
val expected = orderedProjects.flatMap(projectScopes)
val ordering = orderedProjects.zipWithIndex.toMap
val byProjectOrder: Scope => Option[Int] = _.project match
case Select(ref: ProjectRef) => ordering.get(ref)
case _ => None
val actual = ScopeFilter.orderedScopesForTests(expected.reverse, byProjectOrder)
actual ==== expected
}
)
)
private val projectIdGen: Gen[String] =
for
head <- Gen.char('a', 'z')
tail <- Gen.string(Gen.alphaNum, Range.linear(0, 6))
yield s"$head$tail"
private val projectIdsGen: Gen[List[String]] =
projectIdGen.list(Range.linear(1, 6)).filter { ids =>
ids.distinct.size == ids.size
}
private def toProjectRef(id: String): ProjectRef =
ProjectRef(buildUri, id)
private def projectScopes(project: ProjectRef): List[Scope] =
List(
Scope(Select(project), Select(ConfigKey("compile")), Zero, Zero),
Scope(Select(project), Select(ConfigKey("test")), Zero, Zero),
Scope(Select(project), Zero, Zero, Zero)
)
}

View File

@ -0,0 +1,48 @@
lazy val marker = taskKey[String]("Identifies the selected scope.")
lazy val checkProjectOrdering = taskKey[Unit]("Checks deterministic project ordering.")
lazy val checkConfigOrdering = taskKey[Unit]("Checks deterministic configuration ordering.")
lazy val checkAllOrdering = taskKey[Unit]("Runs all ScopeFilter ordering checks.")
def assertEquals[A](actual: Seq[A], expected: Seq[A], context: String): Unit =
assert(actual == expected, s"$context expected=$expected actual=$actual")
lazy val a = project.settings(
marker := "a-global",
Compile / marker := "a-compile",
Test / marker := "a-test",
)
lazy val b = project.settings(
marker := "b-global",
Compile / marker := "b-compile",
Test / marker := "b-test",
)
lazy val c = project.settings(
marker := "c-global",
Compile / marker := "c-compile",
Test / marker := "c-test",
)
lazy val root = project
.aggregate(a, b, c)
.settings(
checkProjectOrdering := {
val values = marker.all(ScopeFilter(inProjects(c, a, b))).value
assertEquals(values, Seq("c-global", "a-global", "b-global"), "project ordering")
},
checkConfigOrdering := {
val values = marker.all(
ScopeFilter(
projects = inProjects(b),
configurations = inConfigurations(Test, Compile) || inZeroConfiguration
)
).value
assertEquals(values, Seq("b-compile", "b-test", "b-global"), "configuration ordering")
},
checkAllOrdering := {
checkProjectOrdering.value
checkConfigOrdering.value
},
)

View File

@ -0,0 +1 @@
> checkAllOrdering