From a7d5f45515bddcd55fcd9f2f5fc5b0e5429fd3de Mon Sep 17 00:00:00 2001 From: Douglas Ma Date: Sat, 21 Feb 2026 01:35:46 -0500 Subject: [PATCH] [2.x] fix: Reject java.io.File as cached task output type (#8766) Change the behavior when a cached task's output type contains java.io.File: instead of silently skipping the cache, throw a compile-time error with a message recommending xsbti.VirtualFileRef, xsbti.HashedVirtualFileRef, or xsbti.VirtualFile, and linking to the caching documentation. Internal sbt tasks that return File types are wrapped with Def.uncached to opt out of caching. Fixes https://github.com/sbt/sbt/issues/8762 Generated-by: GitHub Copilot (Claude) * [2.x] fix: Reject java.io.File as cached task output type Use @transient on File-returning keys in Keys.scala instead of wrapping tasks with Def.uncached in Defaults.scala. This ensures build users who rewire these tasks also get caching skipped automatically. - Add @transient to 19 File-returning taskKey definitions in Keys.scala - Revert Def.uncached wrappers from Defaults.scala - Error at compile time when File is used as cached task output type - Update error message to recommend @transient and link to docs - Update scripted tests to use @transient approach Fixes #8762 Generated-by: GitHub Copilot (Claude Opus 4.6) --- .../sbt/internal/util/appmacro/Cont.scala | 85 ++++++++++++------- main/src/main/scala/sbt/Keys.scala | 19 +++++ .../src/sbt-test/actions/generator/build.sbt | 1 + .../src/sbt-test/actions/task-map/build.sbt | 16 ++-- .../sbt-test/cache/skip-file-cache/build.sbt | 16 ++++ .../src/sbt-test/cache/skip-file-cache/test | 8 ++ sbt-app/src/sbt-test/nio/glob-dsl/build.sbt | 2 + .../src/sbt-test/project/unified/build.sbt | 1 + 8 files changed, 106 insertions(+), 42 deletions(-) create mode 100644 sbt-app/src/sbt-test/cache/skip-file-cache/build.sbt create mode 100644 sbt-app/src/sbt-test/cache/skip-file-cache/test 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 ba010d86b..5d80ffad0 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 @@ -178,6 +178,16 @@ trait Cont: case Left(l) => (l, TypeRepr.of[Effect[A]]) case Right(r) => (r, faTpe) + def containsFileType[A1: Type]: Boolean = + val fileRepr = TypeRepr.of[java.io.File] + def containsFile(tpe: TypeRepr): Boolean = + if tpe =:= fileRepr then true + else + tpe.dealias match + case AppliedType(_, args) => args.exists(containsFile) + case _ => false + containsFile(TypeRepr.of[A1]) + val inputBuf = ListBuffer[Input]() val outputBuf = ListBuffer[Output]() @@ -342,38 +352,49 @@ trait Cont: cacheConfigExpr: Expr[BuildWideCacheConfiguration], tags: List[CacheLevelTag], )(body: Expr[A1], input: Expr[A2]): Expr[A1] = - val codeContentHash = - try Expr[Long](body.show.##) - catch - case e: Throwable => - Expr[Long](Printer.TreeStructure.show(body.asTerm).##) - val extraHash = Expr[Long](0L) - val aJsonFormat = summonJsonFormat[A1] - val aClassTag = summonClassTag[A1] - val inputHashWriter = - if TypeRepr.of[A2] =:= TypeRepr.of[Unit] then - '{ - import BasicJsonProtocol.* - summon[HashWriter[Unit]] - }.asExprOf[HashWriter[A2]] - else summonHashWriter[A2] - val tagsExpr = '{ List(${ Varargs(tags.map(Expr[CacheLevelTag](_))) }*) } - val block = letOutput(outputs, cacheConfigExpr)(body) - '{ - given HashWriter[A2] = $inputHashWriter - given JsonFormat[A1] = $aJsonFormat - given ClassTag[A1] = $aClassTag - ActionCache - .cache( - $input, - codeContentHash = Digest.dummy($codeContentHash), - extraHash = Digest.dummy($extraHash), - tags = $tagsExpr, - config = $cacheConfigExpr, - )({ _ => - $block - }) - } + if containsFileType[A1] then + report.errorAndAbort( + s"""java.io.File is not a valid output type for a cached task. + |Consider using one of the following alternatives: + | - xsbti.HashedVirtualFileRef + | - xsbti.VirtualFileRef + | - xsbti.VirtualFile + |If caching is not needed, annotate the key with @transient, or wrap the task in Def.uncached. + |See https://www.scala-sbt.org/2.x/docs/en/concepts/caching.html#caching-files""".stripMargin + ) + else + val codeContentHash = + try Expr[Long](body.show.##) + catch + case e: Throwable => + Expr[Long](Printer.TreeStructure.show(body.asTerm).##) + val extraHash = Expr[Long](0L) + val aJsonFormat = summonJsonFormat[A1] + val aClassTag = summonClassTag[A1] + val inputHashWriter = + if TypeRepr.of[A2] =:= TypeRepr.of[Unit] then + '{ + import BasicJsonProtocol.* + summon[HashWriter[Unit]] + }.asExprOf[HashWriter[A2]] + else summonHashWriter[A2] + val tagsExpr = '{ List(${ Varargs(tags.map(Expr[CacheLevelTag](_))) }*) } + val block = letOutput(outputs, cacheConfigExpr)(body) + '{ + given HashWriter[A2] = $inputHashWriter + given JsonFormat[A1] = $aJsonFormat + given ClassTag[A1] = $aClassTag + ActionCache + .cache( + $input, + codeContentHash = Digest.dummy($codeContentHash), + extraHash = Digest.dummy($extraHash), + tags = $tagsExpr, + config = $cacheConfigExpr, + )({ _ => + $block + }) + } // This will generate following code for Def.declareOutput(...): // var $o1: VirtualFile = null diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 439f5812d..bed83e0d5 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -157,9 +157,12 @@ object Keys { val javaSource = settingKey[File]("Default Java source directory.").withRank(ASetting) val sourceDirectories = settingKey[Seq[File]]("List of all source directories, both managed and unmanaged.").withRank(AMinusSetting) val unmanagedSourceDirectories = settingKey[Seq[File]]("Unmanaged source directories, which contain manually created sources.").withRank(ASetting) + @transient val unmanagedSources = taskKey[Seq[File]]("Unmanaged sources, which are manually created.").withRank(BPlusTask) val managedSourceDirectories = settingKey[Seq[File]]("Managed source directories, which contain sources generated by the build.").withRank(BSetting) + @transient val managedSources = taskKey[Seq[File]]("Sources generated by the build.").withRank(BTask) + @transient val sources = taskKey[Seq[File]]("All sources, both managed and unmanaged.").withRank(BTask) val sourcesInBase = settingKey[Boolean]("If true, sources from the project's base directory are included as main sources.") @@ -171,10 +174,13 @@ object Keys { val resourceDirectory = settingKey[File]("Default unmanaged resource directory, used for user-defined resources.").withRank(ASetting) val resourceManaged = settingKey[File]("Default managed resource directory, used when generating resources.").withRank(BSetting) val unmanagedResourceDirectories = settingKey[Seq[File]]("Unmanaged resource directories, containing resources manually created by the user.").withRank(AMinusSetting) + @transient val unmanagedResources = taskKey[Seq[File]]("Unmanaged resources, which are manually created.").withRank(BPlusTask) val managedResourceDirectories = settingKey[Seq[File]]("List of managed resource directories.").withRank(AMinusSetting) + @transient val managedResources = taskKey[Seq[File]]("Resources generated by the build.").withRank(BTask) val resourceDirectories = settingKey[Seq[File]]("List of all resource directories, both managed and unmanaged.").withRank(BPlusSetting) + @transient val resources = taskKey[Seq[File]]("All resource files, both managed and unmanaged.").withRank(BTask) private[sbt] val resourceDigests = taskKey[Seq[Digest]]("All resource files, both managed and unmanaged.").withRank(BTask) @@ -257,6 +263,7 @@ object Keys { val manipulateBytecode = taskKey[CompileResult]("Manipulates generated bytecode").withRank(BTask) val compileIncremental = taskKey[(Boolean, VirtualFileRef, HashedVirtualFileRef)]("Actually runs the incremental compilation").withRank(DTask) val previousCompile = taskKey[PreviousResult]("Read the incremental compiler analysis from disk").withRank(DTask) + @transient val tastyFiles = taskKey[Seq[File]]("Returns the TASTy files produced by compilation").withRank(DTask) private[sbt] val compileScalaBackend = taskKey[CompileResult]("Compiles only Scala sources if pipelining is enabled. Compiles both Scala and Java sources otherwise").withRank(Invisible) private[sbt] val compileEarly = taskKey[CompileAnalysis]("Compiles only Scala sources if pipelining is enabled, and produce an early output (pickle JAR)").withRank(Invisible) @@ -273,17 +280,21 @@ object Keys { val earlyCompileAnalysisTargetRoot = settingKey[File]("The output directory to produce Zinc Analysis files").withRank(DSetting) @transient val compileAnalysisFile = taskKey[File]("Zinc analysis storage.").withRank(DSetting) + @transient val earlyCompileAnalysisFile = taskKey[File]("Zinc analysis storage for early compilation").withRank(DSetting) @transient val compileIncSetup = taskKey[Setup]("Configures aspects of incremental compilation.").withRank(DTask) val compilerCache = taskKey[GlobalsCache]("Cache of scala.tools.nsc.Global instances. This should typically be cached so that it isn't recreated every task run.").withRank(DTask) val stateCompilerCache = AttributeKey[GlobalsCache]("stateCompilerCache", "Internal use: Global cache.") + @transient val classpathEntryDefinesClass = taskKey[File => DefinesClass]("Internal use: provides a function that determines whether the provided file contains a given class.").withRank(Invisible) private[sbt] val classpathDefinesClassCache = settingKey[VirtualFileValueCache[DefinesClass]]("Internal use: a cache of jar classpath entries that persists across command evaluations.").withRank(Invisible) val persistJarClasspath = settingKey[Boolean]("Toggles whether or not to cache jar classpath entries between command evaluations") val classpathEntryDefinesClassVF = taskKey[VirtualFile => DefinesClass]("Internal use: provides a function that determines whether the provided file contains a given class.").withRank(Invisible) + @transient val doc = taskKey[File]("Generates API documentation.").withRank(AMinusTask) + @transient val copyResources = taskKey[Seq[(File, File)]]("Copies resources to the output directory.").withRank(AMinusTask) val aggregate = settingKey[Boolean]("Configures task aggregation.").withRank(BMinusSetting) val sourcePositionMappers = taskKey[Seq[xsbti.Position => Option[xsbti.Position]]]("Maps positions in generated source files to the original source it was generated from").withRank(DTask) @@ -407,7 +418,9 @@ object Keys { val projectInfo = settingKey[ModuleInfo]("Addition project information like formal name, homepage, licenses etc.").withRank(CSetting) val defaultConfiguration = settingKey[Option[Configuration]]("Defines the configuration used when none is specified for a dependency in ivyXML.").withRank(CSetting) + @transient val products = taskKey[Seq[File]]("Build products that get packaged.").withRank(BMinusTask) + @transient val productDirectories = taskKey[Seq[File]]("Base directories of build products.").withRank(CTask) val exportJars = settingKey[Boolean]("Determines whether the exported classpath for this project contains classes (false) or a packaged jar (true).").withRank(BSetting) val exportedProducts = taskKey[Classpath]("Build products that go on the exported classpath.").withRank(CTask) @@ -539,6 +552,7 @@ object Keys { val updateClassifiers = TaskKey[UpdateReport]("updateClassifiers", "Resolves and optionally retrieves classified artifacts, such as javadocs and sources, for dependency definitions, transitively.", BPlusTask, update) val transitiveClassifiers = settingKey[Seq[String]]("List of classifiers used for transitively obtaining extra artifacts for sbt or declared dependencies.").withRank(BSetting) val updateSbtClassifiers = TaskKey[UpdateReport]("updateSbtClassifiers", "Resolves and optionally retrieves classifiers, such as javadocs and sources, for sbt, transitively.", BPlusTask, updateClassifiers) + @transient val dependencyLock = taskKey[File]("Generates a dependency lock file from the current resolution.").withRank(BTask) val dependencyLockCheck = taskKey[Unit]("Checks if the dependency lock file is up-to-date.").withRank(BTask) val dependencyLockFile = settingKey[File]("The location of the dependency lock file.").withRank(CSetting) @@ -562,9 +576,12 @@ object Keys { val allCredentials = taskKey[Seq[Credentials]]("Aggregated credentials across current and root subprojects. Do not rewire this task.").withRank(DTask) val makePom = taskKey[HashedVirtualFileRef]("Generates a pom for publishing when publishing Maven-style.").withRank(BPlusTask) + @transient val deliver = taskKey[File]("Generates the Ivy file for publishing to a repository.").withRank(BTask) + @transient val deliverLocal = taskKey[File]("Generates the Ivy file for publishing to the local repository.").withRank(BTask) // makeIvyXml is currently identical to the confusingly-named "deliver", which may be deprecated in the future + @transient val makeIvyXml = taskKey[File]("Generates the Ivy file for publishing to a repository.").withRank(BTask) /** BOM-resolved ModuleIDs for deps that had \"*\" (version from BOM); emitted as forced deps in published ivy.xml (sbt#4531). */ val resolvedDependencies = taskKey[Seq[ModuleID]]("") @@ -605,6 +622,7 @@ object Keys { val retrieveConfiguration = settingKey[Option[RetrieveConfiguration]]("Configures retrieving dependencies to the current build.").withRank(DSetting) val offline = settingKey[Boolean]("Configures sbt to work without a network connection where possible.").withRank(ASetting) val ivyPaths = settingKey[IvyPaths]("Configures paths used by Ivy for dependency management.").withRank(DSetting) + @transient val dependencyCacheDirectory = taskKey[File]("The base directory for cached dependencies.").withRank(DTask) val libraryDependencies = settingKey[Seq[ModuleID]]("Declares managed dependencies.").withRank(APlusSetting) val dependencyOverrides = settingKey[Seq[ModuleID]]("Declares managed dependency overrides.").withRank(BSetting) @@ -635,6 +653,7 @@ object Keys { val libraryDependencySchemes = settingKey[Seq[ModuleID]]("""Version scheme to use for specific modules set as "org" %% "name" % "": Supported values are "early-semver", "pvp", "semver-spec", "always", and "strict".""").withRank(BSetting) @transient val stagingDirectory = settingKey[File]("Local staging directory for Sonatype publishing").withRank(CSetting) + @transient val sonaBundle = taskKey[File]("Local bundle for Sonatype publishing").withRank(DTask) val localStaging = settingKey[Option[Resolver]]("Local staging resolver for Sonatype publishing").withRank(CSetting) val sonaDeploymentName = settingKey[String]("The name used for deployment").withRank(DSetting) diff --git a/sbt-app/src/sbt-test/actions/generator/build.sbt b/sbt-app/src/sbt-test/actions/generator/build.sbt index 5c5528673..c8c6e9aff 100644 --- a/sbt-app/src/sbt-test/actions/generator/build.sbt +++ b/sbt-app/src/sbt-test/actions/generator/build.sbt @@ -1,3 +1,4 @@ +@transient val buildInfo = taskKey[Seq[File]]("generates the build info") ThisBuild / scalaVersion := "2.12.21" diff --git a/sbt-app/src/sbt-test/actions/task-map/build.sbt b/sbt-app/src/sbt-test/actions/task-map/build.sbt index ba036da3a..d9d76d893 100644 --- a/sbt-app/src/sbt-test/actions/task-map/build.sbt +++ b/sbt-app/src/sbt-test/actions/task-map/build.sbt @@ -1,36 +1,32 @@ +@transient val taskA = taskKey[File]("") +@transient val taskB = taskKey[File]("") +@transient val taskE = taskKey[File]("") +@transient val taskF = taskKey[File]("") scalaVersion := "3.3.1" name := "task-map" taskA := { - val c = fileConverter.value touch(target.value / "a") - Def.declareOutput(c.toVirtualFile((target.value / "a").toPath())) target.value / "a" } taskB := { - val c = fileConverter.value touch(target.value / "b") - Def.declareOutput(c.toVirtualFile((target.value / "b").toPath())) target.value / "b" } taskE := { - val c = fileConverter.value touch(target.value / "e") - Def.declareOutput(c.toVirtualFile((target.value / "e").toPath())) target.value / "e" } taskF := { - val c = fileConverter.value touch(target.value / "f") - Def.declareOutput(c.toVirtualFile((target.value / "f").toPath())) target.value / "f" } @@ -38,13 +34,13 @@ taskF := { // means "a" will be triggered by "b" // said differently, invoking "b" will run "b" and then run "a" -taskA := Def.uncached(taskA.triggeredBy(taskB).value) +taskA := taskA.triggeredBy(taskB).value // e <<= e runBefore f // means "e" will be run before running "f" // said differently, invoking "f" will run "e" and then run "f" -taskE := Def.uncached(taskE.runBefore(taskF).value) +taskE := taskE.runBefore(taskF).value // test utils def touch(f: File): File = { IO.touch(f); f } diff --git a/sbt-app/src/sbt-test/cache/skip-file-cache/build.sbt b/sbt-app/src/sbt-test/cache/skip-file-cache/build.sbt new file mode 100644 index 000000000..de966cb8c --- /dev/null +++ b/sbt-app/src/sbt-test/cache/skip-file-cache/build.sbt @@ -0,0 +1,16 @@ +import java.io.File + +@transient +val myFileTask = taskKey[File]("task that returns File") +val badFileTask = taskKey[File]("task without @transient that should fail to cache") +val checkFileTask = taskKey[Unit]("verifies file task returns correct value") + +myFileTask := { + new File(scalaVersion.value) +} + +checkFileTask := { + val f = myFileTask.value + val expected = new File(scalaVersion.value) + assert(f == expected, s"Expected $expected but got $f") +} diff --git a/sbt-app/src/sbt-test/cache/skip-file-cache/test b/sbt-app/src/sbt-test/cache/skip-file-cache/test new file mode 100644 index 000000000..76af7cfe1 --- /dev/null +++ b/sbt-app/src/sbt-test/cache/skip-file-cache/test @@ -0,0 +1,8 @@ +# @transient File-returning tasks should not be cached, so +# changing scalaVersion should immediately be reflected. +> checkFileTask +> set scalaVersion := "2.13.18" +> checkFileTask + +# Using File as the return type of tasks without @transient should fail compilation. +-> set badFileTask := { new java.io.File(scalaVersion.value) } diff --git a/sbt-app/src/sbt-test/nio/glob-dsl/build.sbt b/sbt-app/src/sbt-test/nio/glob-dsl/build.sbt index d8728a4dd..f8050eeb0 100644 --- a/sbt-app/src/sbt-test/nio/glob-dsl/build.sbt +++ b/sbt-app/src/sbt-test/nio/glob-dsl/build.sbt @@ -2,6 +2,7 @@ import sbt.internal.FileChangesMacro.inputFiles // The project contains two files: { Foo.txt, Bar.md } in the subdirector base/subdir/nested-subdir // Check that we can correctly extract Foo.txt with a recursive source +@transient val foo = taskKey[Seq[File]]("Retrieve Foo.txt") foo / fileInputs += baseDirectory.value.toGlob / ** / "*.txt" @@ -13,6 +14,7 @@ val checkFoo = taskKey[Unit]("Check that the Foo.txt file is retrieved") checkFoo := assert(foo.value == Seq(baseDirectory.value / "base/subdir/nested-subdir/Foo.txt")) // Check that we can correctly extract Bar.md with a non-recursive source +@transient val bar = taskKey[Seq[File]]("Retrieve Bar.md") bar / fileInputs += baseDirectory.value.toGlob / "base" / "subdir" / "nested-subdir" / "*.md" diff --git a/sbt-app/src/sbt-test/project/unified/build.sbt b/sbt-app/src/sbt-test/project/unified/build.sbt index ddf8bf56c..9bc59970f 100644 --- a/sbt-app/src/sbt-test/project/unified/build.sbt +++ b/sbt-app/src/sbt-test/project/unified/build.sbt @@ -8,6 +8,7 @@ val uTest = "com.lihaoyi" %% "utest" % "0.5.3" val foo = taskKey[Int]("") val bar = taskKey[Int]("") val baz = inputKey[Unit]("") +@transient val buildInfo = taskKey[Seq[File]]("The task that generates the build info.") lazy val root = (project in file("."))