From 4193607fa35e31f6621cddda1414e36b51f6b7b1 Mon Sep 17 00:00:00 2001 From: Jozef Koval Date: Tue, 16 Jun 2026 00:43:40 +0200 Subject: [PATCH] [2.x] fix: Resolve Seq[Initialize[Task[T]]].join to Initialize[Task[Seq[T]]] (#899) Fixes #899. `.join` on a `Seq[Initialize[Task[T]]]` could resolve through the generic `Initialize.joinInitialize` conversion (yielding `Initialize[Seq[Task[T]]]`) instead of the task-flattening `Initialize[Task[Seq[T]]]`, whenever the task-specialized `Scoped.richTaskSeq` was not in scope. Add the task join as an extension method on `Seq[Def.Initialize[Task[A]]]` in `object Def`. Member resolution tries extension methods before implicit conversions, so it is selected ahead of the generic conversion regardless of whether `sbt.Scoped` is in scope -- and, being in a different resolution phase than the existing `Scoped.richTaskSeq` conversion, it does not become a second ambiguous candidate for `TaskKey`/`SettingKey` seqs (where `Scoped` is a base type). `Scoped.richTaskSeq` is left unchanged as the existing public implicit. Also: - Drop the now-unnecessary `import sbt.Scoped.richTaskSeq` from `BuildServerProtocol` and simplify `ScopeFilter.all`'s `.join(_.join)` to `.join`. - Make the two `dependsOn(Initialize[? <: Task[?]]*)` overloads delegate to `dependsOnSeq`, which uses the explicit `Initialize.joinAny[Task]` raw-task form, instead of relying on `.join` resolving to the generic conversion. - Add a compile-only regression test pinning resolution for both a plain `Seq[Initialize[Task[Int]]]` and a `Seq[TaskKey[Int]]`, inferring the result with no expected type. --- main-settings/src/main/scala/sbt/Def.scala | 11 +++++++ .../src/main/scala/sbt/Structure.scala | 6 ++-- .../internal/join/JoinResolutionTest.scala | 30 +++++++++++++++++++ main/src/main/scala/sbt/ScopeFilter.scala | 5 +--- .../internal/server/BuildServerProtocol.scala | 1 - 5 files changed, 44 insertions(+), 9 deletions(-) create mode 100644 main-settings/src/test/scala/sbt/internal/join/JoinResolutionTest.scala diff --git a/main-settings/src/main/scala/sbt/Def.scala b/main-settings/src/main/scala/sbt/Def.scala index 726a4eda8..e0222d7b2 100644 --- a/main-settings/src/main/scala/sbt/Def.scala +++ b/main-settings/src/main/scala/sbt/Def.scala @@ -495,4 +495,15 @@ sealed trait InitializeImplicits { self: Def.type => implicit def initTaskOps[T](x: Def.Initialize[Task[T]]): Def.InitTaskOps[T] = new Def.InitTaskOps(x) + + /** + * Flattens a sequence of task initializations into one task producing the sequence of results. + * + * This is an extension method rather than an implicit conversion so that it is selected ahead of + * the generic `Initialize.joinInitialize` conversion (which would otherwise yield + * `Initialize[Seq[Task[A]]]`) regardless of whether `sbt.Scoped` is in scope, and without becoming + * a second, ambiguous conversion candidate for `TaskKey`/`SettingKey` seqs. See issue #899. + */ + extension [A](in: Seq[Def.Initialize[Task[A]]]) + def join: Def.Initialize[Task[Seq[A]]] = Scoped.richTaskSeq(in).join } diff --git a/main-settings/src/main/scala/sbt/Structure.scala b/main-settings/src/main/scala/sbt/Structure.scala index ce80527ca..6e5c3c904 100644 --- a/main-settings/src/main/scala/sbt/Structure.scala +++ b/main-settings/src/main/scala/sbt/Structure.scala @@ -364,7 +364,7 @@ object Scoped: // Task-specific extensions def dependsOn(tasks: Initialize[? <: Task[?]]*): Initialize[Task[A1]] = - init.zipWith(tasks.asInstanceOf[Seq[Initialize[Task[?]]]].join)(_.dependsOn(_*)) + dependsOnSeq(tasks.asInstanceOf[Seq[AnyInitTask]]) def dependsOnTask[A2](task1: Initialize[Task[A2]]): Initialize[Task[A1]] = dependsOnSeq(Seq[AnyInitTask](task1.asInstanceOf[AnyInitTask])) def dependsOnSeq(tasks: Seq[AnyInitTask]): Initialize[Task[A1]] = @@ -412,9 +412,7 @@ object Scoped: // InputTask specific extensions @targetName("dependsOnInitializeInputTask") def dependsOn(tasks: Initialize[? <: Task[?]]*): Initialize[InputTask[A1]] = - init.zipWith(tasks.asInstanceOf[Seq[Initialize[Task[?]]]].join)((thisTask, deps) => - thisTask.mapTask(_.dependsOn(deps*)) - ) + dependsOnSeq(tasks.asInstanceOf[Seq[AnyInitTask]]) @targetName("dependsOnTaskInitializeInputTask") def dependsOnTask[B1](task1: Initialize[Task[B1]]): Initialize[InputTask[A1]] = dependsOnSeq(Seq[AnyInitTask](task1.asInstanceOf[AnyInitTask])) diff --git a/main-settings/src/test/scala/sbt/internal/join/JoinResolutionTest.scala b/main-settings/src/test/scala/sbt/internal/join/JoinResolutionTest.scala new file mode 100644 index 000000000..04df7d110 --- /dev/null +++ b/main-settings/src/test/scala/sbt/internal/join/JoinResolutionTest.scala @@ -0,0 +1,30 @@ +/* + * 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.internal.join + +import sbt.{ Def, Task, TaskKey } + +object JoinResolutionTest: + // `.join` on a seq of task initializes must resolve to Initialize[Task[Seq[Int]]] (task-flattening), + // not the generic Initialize[Seq[Task[Int]]]. Each result is inferred with NO expected type first + // (`val joined = ...`) and only then checked against the declared return type, so the test fails if + // resolution depends on an expected type to pick the right form. This package does not import + // `sbt.Scoped`, so it pins that the resolution holds via implicit scope alone. Guards issue #899. + + // The originally reported shape: a plain `Seq[Initialize[Task[A]]]` (no `Scoped` base type). + def check(in: Seq[Def.Initialize[Task[Int]]]): Def.Initialize[Task[Seq[Int]]] = + val joined = in.join + joined + + // `TaskKey` extends `Scoped`, so this also pins that the task join wins without becoming ambiguous + // with the `Scoped.richTaskSeq` conversion that is in implicit scope here. + def checkTaskKey(in: Seq[TaskKey[Int]]): Def.Initialize[Task[Seq[Int]]] = + val joined = in.join + joined +end JoinResolutionTest diff --git a/main/src/main/scala/sbt/ScopeFilter.scala b/main/src/main/scala/sbt/ScopeFilter.scala index 3828f822b..28f9c4e80 100644 --- a/main/src/main/scala/sbt/ScopeFilter.scala +++ b/main/src/main/scala/sbt/ScopeFilter.scala @@ -117,13 +117,10 @@ object ScopeFilter { * static inspections will not show them. */ def all(sfilter: => ScopeFilter): Initialize[Task[Seq[A]]] = Def.flatMap(getData) { data => - import std.TaskExtra.* val filter = sfilter orderedScopes(filter(data), filter.scopeOrdering(data)) .map(s => Project.inScope(s, i)) - .join( - _.join - ) + .join } private type ScopeAxisOrderingKey = (Int, String) diff --git a/main/src/main/scala/sbt/internal/server/BuildServerProtocol.scala b/main/src/main/scala/sbt/internal/server/BuildServerProtocol.scala index 24d43b197..1786f8156 100644 --- a/main/src/main/scala/sbt/internal/server/BuildServerProtocol.scala +++ b/main/src/main/scala/sbt/internal/server/BuildServerProtocol.scala @@ -17,7 +17,6 @@ import sbt.Def.* import sbt.Keys.* import sbt.ProjectExtra.* import sbt.ScopeFilter.Make.* -import sbt.Scoped.richTaskSeq import sbt.SlashSyntax0.* import sbt.StandardMain.exchange import sbt.internal.bsp.*