From 152c66a34c54b1e3841624a4e1ded2425a8f6877 Mon Sep 17 00:00:00 2001 From: Anatolii Kmetiuk Date: Wed, 16 Sep 2026 12:30:59 +0900 Subject: [PATCH] [2.x] fix: Warn on output declarations in uncached tasks (#9770) --- .../sbt/internal/util/appmacro/Cont.scala | 10 +- .../cache/uncached-declare-output/build.sbt | 96 +++++++++++++++++++ .../cache/uncached-declare-output/test | 10 ++ .../scala/sbt/UncachedOutputWarningSpec.scala | 66 +++++++++++++ 4 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 sbt-app/src/sbt-test/cache/uncached-declare-output/build.sbt create mode 100644 sbt-app/src/sbt-test/cache/uncached-declare-output/test create mode 100644 sbt-app/src/test/scala/sbt/UncachedOutputWarningSpec.scala diff --git a/core-macros/src/main/scala/sbt/internal/util/appmacro/Cont.scala b/core-macros/src/main/scala/sbt/internal/util/appmacro/Cont.scala index 38e5edfea..29b33c746 100644 --- a/core-macros/src/main/scala/sbt/internal/util/appmacro/Cont.scala +++ b/core-macros/src/main/scala/sbt/internal/util/appmacro/Cont.scala @@ -508,7 +508,7 @@ trait Cont: var nowarnQuals: Set[Term] = Set.empty // Called when transforming the tree to add an input. // For `qual` of type F[A], and a `selection` qual.value. - val record = [a] => + lazy val record: [a] => (String, Type[a], Term, Term) => Converted = [a] => (name: String, tpe: Type[a], qual: Term, oldTree: Term) => given t: Type[a] = tpe convert[a](name, qual) transform { (replacement: Term) => @@ -527,7 +527,13 @@ trait Cont: $outputAccRef, ) }.asTerm - else oldTree + else + report.warning( + "Def.declareOutput has no caching effect in an uncached task.", + qual.underlyingArgument.pos, + ) + transformWrappers(qual, record, Symbol.spliceOwner) + end if case WrapOutputDirectoryName => val output = Output( // even though the term is VirtualFileRef, we want the output to make VirtualFile, diff --git a/sbt-app/src/sbt-test/cache/uncached-declare-output/build.sbt b/sbt-app/src/sbt-test/cache/uncached-declare-output/build.sbt new file mode 100644 index 000000000..9d6dcc86f --- /dev/null +++ b/sbt-app/src/sbt-test/cache/uncached-declare-output/build.sbt @@ -0,0 +1,96 @@ +/* + * sbt + * Copyright 2026, Scala center + * Licensed under Apache License 2.0 (see LICENSE) + */ + +import sbt.internal.util.StringVirtualFile1 +import xsbti.VirtualFile + +@transient lazy val zero = taskKey[Unit]("") +@transient lazy val one = taskKey[Unit]("") +@transient lazy val many = taskKey[Unit]("") +@transient lazy val nested = taskKey[Unit]("") +@transient lazy val direct = taskKey[VirtualFile]("") +@transient lazy val viaTask = taskKey[Unit]("") +@transient lazy val viaUncachedTask = taskKey[Unit]("") +@transient lazy val viaDynamic = taskKey[Unit]("") +@transient lazy val checkRuns = taskKey[Unit]("") +lazy val optedOut = taskKey[Unit]("") + +Global / localCacheDirectory := baseDirectory.value / "diskcache" + +zero := { + val output = StringVirtualFile1("zero.txt", "zero") + var calls = 0 + val result = Def.declareOutput { + calls += 1 + output + } + assert(result eq output) + assert(calls == 1) + if false then Def.declareOutput { calls += 1; output } + assert(calls == 1) + val results = List(1, 2, 3).map: _ => + Def.declareOutput { calls += 1; output } + assert(results.forall(_ eq output)) + assert(calls == 4) + val failure = new RuntimeException("argument failure") + val thrown = try + Def.declareOutput(throw failure) + None + catch case e: RuntimeException => Some(e) + assert(thrown.contains(failure)) +} + +one := { + val dir = target.value + val output = StringVirtualFile1("one.txt", "one") + val result = Def.declareOutput(output) + assert(result eq output) + assert(dir.getName.nonEmpty) +} + +many := { + val converter = fileConverter.value + val dir = target.value + val output = converter.toVirtualFile((dir / "many.txt").toPath) + assert(Def.declareOutput(output) eq output) + val counter = dir / "runs" + val previous = if counter.exists then IO.read(counter).toInt else 0 + IO.write(counter, (previous + 1).toString) +} + +direct := StringVirtualFile1("direct.txt", "direct") + +nested := { + val result = Def.declareOutput(direct.value) + assert(result.id == "direct.txt") + val converted = Def.declareOutput(fileConverter.value.toVirtualFile(target.value.toPath)) + assert(converted.id.nonEmpty) + val output = StringVirtualFile1("nested.txt", "nested") + assert(Def.declareOutput(Def.declareOutput(output)) eq output) +} + +optedOut := Def.uncached { + val output = StringVirtualFile1("opted-out.txt", "opted-out") + assert(Def.declareOutput(output) eq output) +} + +viaTask := Def.task { + val output = StringVirtualFile1("task.txt", "task") + assert(Def.declareOutput(output) eq output) +}.value + +viaUncachedTask := Def.uncachedTask { + val output = StringVirtualFile1("uncached-task.txt", "uncached-task") + assert(Def.declareOutput(output) eq output) +}.value + +viaDynamic := Def.taskDyn { + val output = StringVirtualFile1("dynamic.txt", "dynamic") + assert(Def.declareOutput(output) eq output) + Def.task(()) +}.value + +checkRuns := assert(IO.read(target.value / "runs") == "2") diff --git a/sbt-app/src/sbt-test/cache/uncached-declare-output/test b/sbt-app/src/sbt-test/cache/uncached-declare-output/test new file mode 100644 index 000000000..6c018a714 --- /dev/null +++ b/sbt-app/src/sbt-test/cache/uncached-declare-output/test @@ -0,0 +1,10 @@ +> zero +> one +> many +> many +> checkRuns +> nested +> optedOut +> viaTask +> viaUncachedTask +> viaDynamic diff --git a/sbt-app/src/test/scala/sbt/UncachedOutputWarningSpec.scala b/sbt-app/src/test/scala/sbt/UncachedOutputWarningSpec.scala new file mode 100644 index 000000000..938322329 --- /dev/null +++ b/sbt-app/src/test/scala/sbt/UncachedOutputWarningSpec.scala @@ -0,0 +1,66 @@ +/* + * sbt + * Copyright 2026, Scala center + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt + +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.interfaces.Diagnostic.WARNING +import dotty.tools.dotc.reporting.Diagnostic +import hedgehog.Result +import hedgehog.runner.* +import java.nio.file.{ Path, Paths } +import sbt.internal.{ Eval, EvalImports, EvalReporter } +import scala.io.Source +import scala.util.Using + +object UncachedOutputWarningSpec extends Properties: + private val warning = "Def.declareOutput has no caching effect in an uncached task." + private val imports = EvalImports(Seq("import sbt.*", "import sbt.given", "import sbt.Keys.*")) + private lazy val classpath: Seq[Path] = + val resource = Option(getClass.getResourceAsStream("/sbt-eval-classpath.txt")) + .getOrElse(sys.error("Missing generated sbt evaluator classpath")) + Using.resource(Source.fromInputStream(resource, "UTF-8")): + _.getLines().map(Paths.get(_)).toVector + + private val body = """{ + | val output = sbt.internal.util.StringVirtualFile1("output.txt", "content") + | Def.declareOutput(output) + | () + |}""".stripMargin + + override def tests: List[Test] = List( + ("uncached task", s"Def.uncachedTask $body", Vector(WARNING -> warning)), + ( + "transient task", + s"object Build { @transient lazy val task = taskKey[Unit](\"\"); val setting = task := $body }; Build.setting", + Vector(WARNING -> warning) + ), + ( + "opted-out task", + s"val task = taskKey[Unit](\"\"); task := Def.uncached $body", + Vector(WARNING -> warning) + ), + ("cached task", s"Def.cachedTask $body", Vector.empty), + ("uncached task without an output declaration", "Def.uncachedTask(())", Vector.empty), + ).map: (name, code, expected) => + example( + name, { + val reporter = new RecordingReporter + val evaluator = + new Eval(Nil, classpath, backingDir = None, mkReporter = Some(() => reporter)) + evaluator.eval(code, imports, None, "uncached-output-build.sbt", 1) + Result.assert(reporter.diagnostics == expected).log(reporter.diagnostics.mkString("\n")) + } + ) + + private final class RecordingReporter extends EvalReporter: + var diagnostics = Vector.empty[(Int, String)] + + override def doReport(diagnostic: Diagnostic)(using Context): Unit = + diagnostics :+= diagnostic.level -> diagnostic.msg.message + + override def finalReport(sourceName: String): Unit = () +end UncachedOutputWarningSpec