From d9b912288a7a7fcc6b2935faf8b310294ad8a9f6 Mon Sep 17 00:00:00 2001 From: Matt Dziuban Date: Wed, 20 May 2026 15:31:06 -0400 Subject: [PATCH 1/4] Add tests for `ClassStamper`. --- .../scala/sbt/internal/IncrementalTest.scala | 20 +- .../scala/sbt/internal/ClassStamperTest.scala | 364 ++++++++++++++++++ 2 files changed, 379 insertions(+), 5 deletions(-) create mode 100644 main/src/test/scala/sbt/internal/ClassStamperTest.scala diff --git a/main/src/main/scala/sbt/internal/IncrementalTest.scala b/main/src/main/scala/sbt/internal/IncrementalTest.scala index 18b8c36eb..3b13d6e5e 100644 --- a/main/src/main/scala/sbt/internal/IncrementalTest.scala +++ b/main/src/main/scala/sbt/internal/IncrementalTest.scala @@ -160,15 +160,25 @@ end TestStatus * ClassStamper provides `transitiveStamp` method to calculate a unique * fingerprint, which will be used for runtime invalidation. */ -class ClassStamper( - classpath: Seq[Attributed[HashedVirtualFileRef]], +class ClassStamper private[sbt] ( + analyses0: => Seq[Analysis], converter: FileConverter, ): + def this( + classpath: Seq[Attributed[HashedVirtualFileRef]], + converter: FileConverter, + ) = + this( + classpath + .flatMap(a => BuildDef.extractAnalysis(a.metadata, converter)) + .collect { case analysis: Analysis => analysis }, + converter, + ) + private val stamps = mutable.Map.empty[String, SortedSet[Digest]] private val internalStamps = mutable.Map.empty[String, SortedSet[Digest]] - private lazy val analyses = classpath - .flatMap(a => BuildDef.extractAnalysis(a.metadata, converter)) - .collect { case analysis: Analysis => analysis } + // Cached so by-name `analyses0` is only evaluated once + private lazy val analyses = analyses0 private val stampVf: VirtualFileRef => Digest = CacheImplicits.virtualFileRefToDigest(_)(converter) diff --git a/main/src/test/scala/sbt/internal/ClassStamperTest.scala b/main/src/test/scala/sbt/internal/ClassStamperTest.scala new file mode 100644 index 000000000..d1b71d06b --- /dev/null +++ b/main/src/test/scala/sbt/internal/ClassStamperTest.scala @@ -0,0 +1,364 @@ +/* + * 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 +package internal + +import hedgehog.* +import hedgehog.runner.* +import _root_.sbt.internal.inc.{ Analysis, APIs, Relations } +import _root_.sbt.internal.inc.EmptyStamp +import _root_.sbt.util.{ Digest, Level, Logger } +import xsbti.{ FileConverter, VirtualFile, VirtualFileRef } +import xsbti.api.{ DependencyContext, ExternalDependency, InternalDependency } +import java.nio.file.{ Files, Path } + +object ClassStamperTest extends Properties: + override def tests: List[Test] = List( + example("returns None for unknown class", returnsNoneForUnknownClass), + example("returns None for empty analyses", returnsNoneForEmptyAnalyses), + example("returns Some for class with library dep", returnsSomeForClassWithLibraryDep), + example("digest reflects internal deps", digestReflectsInternalDeps), + example("digest reflects library deps", digestReflectsLibraryDeps), + example("external dep without library is no-op", externalDepWithoutLibraryIsNoOp), + example("digest is order-independent", digestIsOrderIndependent), + example("digest is deterministic", digestIsDeterministic), + example("extraHashes changes the digest", extraHashesChangesDigest), + example("multiple analyses are walked", multipleAnalysesAreWalked), + example("cycles do not loop forever", cyclesDoNotLoopForever), + example("transitive contributions flow up", transitiveContributionsFlowUp), + example("regression: golden digests", regressionGoldens), + ) + + /** + * Byte-for-byte regression test of `transitiveStamp`'s output. The library + * deps' contributions hash file content (= `name`), so values are stable + * across temp-dir paths. + */ + def regressionGoldens: Result = + val internalDepsAnalysis = analysisOf( + "AB", + classes = Seq("A" -> "A", "B" -> "B"), + internalDeps = Seq("A" -> "B"), + externalDeps = Seq("A" -> "ext.X", "B" -> "ext.Y"), + libraryDeps = Seq(lib("foo") -> "ext.X", lib("bar") -> "ext.Y"), + ) + val libraryDepsAnalysis = analysisOf( + "A", + classes = Seq("A" -> "A"), + externalDeps = Seq("A" -> "ext.X"), + libraryDeps = Seq(lib("foo") -> "ext.X"), + ) + val transitiveAnalysis = analysisOf( + "ABC", + classes = Seq("A" -> "A", "B" -> "B", "C" -> "C"), + internalDeps = Seq("A" -> "B", "B" -> "C"), + externalDeps = Seq("C" -> "ext.C"), + libraryDeps = Seq(lib("c1") -> "ext.C"), + ) + val cyclesAnalysis = analysisOf( + "AB", + classes = Seq("A" -> "A", "B" -> "B"), + internalDeps = Seq("A" -> "B", "B" -> "A"), + externalDeps = Seq("A" -> "ext.X", "B" -> "ext.Y"), + libraryDeps = Seq(lib("foo") -> "ext.X", lib("bar") -> "ext.Y"), + ) + + Result.all( + List( + stamp(stamper(internalDepsAnalysis), "A") ==== + Some( + Digest("sha256-92475004e70f41b94750f4a77bf7b430551113b25d3d57169eadca5692bb043d/64") + ), + stamp(stamper(libraryDepsAnalysis), "A") ==== + Some( + Digest("sha256-c7ade88fc7a21498a6a5e5c385e1f68bed822b72aa63c4a9a48a02c2466ee29e/32") + ), + stamp(stamper(transitiveAnalysis), "A") ==== + Some( + Digest("sha256-7e614445eb7c62ec172e4e899e768794dde97a1ce3c8e3f30e0751948cc9e569/32") + ), + stamp(stamper(cyclesAnalysis), "A") ==== + Some( + Digest("sha256-92475004e70f41b94750f4a77bf7b430551113b25d3d57169eadca5692bb043d/64") + ), + stamp(stamper(libraryDepsAnalysis), "A", Seq(Digest.dummy(42L))) ==== + Some( + Digest("sha256-9d0a61172a43b1e7666f9527f82621395ef6a3a0ce5aed5dac317b2a76e8dd94/48") + ), + ) + ) + + // ---------- helpers ---------- + + private val NoopLogger: Logger = new Logger: + override def trace(t: => Throwable): Unit = () + override def success(message: => String): Unit = () + override def log(level: Level.Value, message: => String): Unit = () + + // Shared temp dir backing all "library JAR" refs. Each `lib(name)` writes + // a deterministic file once on first access; `StubConverter.toPath` resolves + // refs into this directory so `Digest.sha256Hash(Path)` can read content. + private lazy val tmpRoot: Path = + val d = Files.createTempDirectory("class-stamper-test") + d.toFile.deleteOnExit() + d + + private def src(name: String): VirtualFileRef = VirtualFileRef.of(s"$name.scala") + + // Each name maps to a real file with content = name, so stamps are + // deterministic across runs and distinct between names. + private def lib(name: String): VirtualFileRef = + val rel = s"lib/$name.jar" + val path = tmpRoot.resolve(rel) + if !Files.exists(path) then + Files.createDirectories(path.getParent) + Files.write(path, name.getBytes("UTF-8")) + path.toFile.deleteOnExit() + VirtualFileRef.of(rel) + + private val StubConverter: FileConverter = new FileConverter: + override def toPath(ref: VirtualFileRef): Path = tmpRoot.resolve(ref.id) + override def toVirtualFile(path: Path): VirtualFile = + sys.error(s"unexpected toVirtualFile($path)") + + /** + * Build a one-source `Analysis` where `srcName.scala` defines the given + * (sourceClassName, binaryClassName) pairs with the given dependencies. + */ + private def analysisOf( + srcName: String, + classes: Iterable[(String, String)] = Nil, + internalDeps: Iterable[(String, String)] = Nil, + externalDeps: Iterable[(String, String)] = Nil, + libraryDeps: Iterable[(VirtualFileRef, String)] = Nil, + ): Analysis = + val rels = Relations.empty.addSource( + src = src(srcName), + products = Nil, + classes = classes, + internalDeps = internalDeps.map { case (a, b) => + InternalDependency.of(a, b, DependencyContext.DependencyByMemberRef) + }, + externalDeps = externalDeps.map { case (a, b) => + ExternalDependency.of( + a, + b, + APIs.emptyAnalyzedClass, + DependencyContext.DependencyByMemberRef, + ) + }, + libraryDeps = libraryDeps.map { case (vf, cn) => (vf, cn, EmptyStamp) }, + ) + Analysis.empty.copy(relations = rels) + + private def stamper(analyses: Analysis*): ClassStamper = + new ClassStamper(analyses.toSeq, StubConverter) + + private def stamp( + s: ClassStamper, + javaClassName: String, + extra: Seq[Digest] = Nil, + ): Option[Digest] = + s.transitiveStamp(javaClassName, extra, NoopLogger) + + // ---------- tests ---------- + + def returnsNoneForUnknownClass: Result = + val a = analysisOf( + "A", + classes = Seq("A" -> "A"), + externalDeps = Seq("A" -> "ext.X"), + libraryDeps = Seq(lib("foo") -> "ext.X"), + ) + stamp(stamper(a), "DoesNotExist") ==== None + + def returnsNoneForEmptyAnalyses: Result = + stamp(stamper(), "Anything") ==== None + + def returnsSomeForClassWithLibraryDep: Result = + val a = analysisOf( + "A", + classes = Seq("A" -> "A"), + externalDeps = Seq("A" -> "ext.X"), + libraryDeps = Seq(lib("foo") -> "ext.X"), + ) + Result.assert(stamp(stamper(a), "A").isDefined) + + def digestReflectsInternalDeps: Result = + val withoutDep = analysisOf( + "A", + classes = Seq("A" -> "A"), + externalDeps = Seq("A" -> "ext.X"), + libraryDeps = Seq(lib("foo") -> "ext.X"), + ) + val withDep = analysisOf( + "AB", + classes = Seq("A" -> "A", "B" -> "B"), + internalDeps = Seq("A" -> "B"), + externalDeps = Seq("A" -> "ext.X", "B" -> "ext.Y"), + libraryDeps = Seq(lib("foo") -> "ext.X", lib("bar") -> "ext.Y"), + ) + val d1 = stamp(stamper(withoutDep), "A") + val d2 = stamp(stamper(withDep), "A") + Result.all( + List( + Result.assert(d1.isDefined), + Result.assert(d2.isDefined), + Result.diffNamed("internal dep should change digest", d1, d2)(_ != _), + ) + ) + + def digestReflectsLibraryDeps: Result = + val a = analysisOf( + "A", + classes = Seq("A" -> "A"), + externalDeps = Seq("A" -> "ext.X"), + libraryDeps = Seq(lib("foo") -> "ext.X"), + ) + val b = analysisOf( + "A", + classes = Seq("A" -> "A"), + externalDeps = Seq("A" -> "ext.X"), + libraryDeps = Seq(lib("bar") -> "ext.X"), + ) + val d1 = stamp(stamper(a), "A") + val d2 = stamp(stamper(b), "A") + Result.all( + List( + Result.assert(d1.isDefined), + Result.assert(d2.isDefined), + Result.diffNamed("digests for different libraries should differ", d1, d2)(_ != _), + ) + ) + + def externalDepWithoutLibraryIsNoOp: Result = + // An external dep with no matching library entry contributes nothing + // (recursion finds no internal product, no library entry). + val noExternal = analysisOf( + "A", + classes = Seq("A" -> "A"), + externalDeps = Seq("A" -> "ext.Real"), + libraryDeps = Seq(lib("real") -> "ext.Real"), + ) + val extraDangling = analysisOf( + "A", + classes = Seq("A" -> "A"), + externalDeps = Seq("A" -> "ext.Real", "A" -> "ext.Dangling"), + libraryDeps = Seq(lib("real") -> "ext.Real"), + ) + stamp(stamper(noExternal), "A") ==== stamp(stamper(extraDangling), "A") + + def digestIsOrderIndependent: Result = + val a = analysisOf( + "AB", + classes = Seq("A" -> "A", "B" -> "B"), + internalDeps = Seq("A" -> "B"), + externalDeps = Seq("A" -> "ext.X", "B" -> "ext.Y"), + libraryDeps = Seq(lib("foo") -> "ext.X", lib("bar") -> "ext.Y"), + ) + val b = analysisOf( + "AB", + classes = Seq("B" -> "B", "A" -> "A"), + internalDeps = Seq("A" -> "B"), + externalDeps = Seq("B" -> "ext.Y", "A" -> "ext.X"), + libraryDeps = Seq(lib("bar") -> "ext.Y", lib("foo") -> "ext.X"), + ) + stamp(stamper(a), "A") ==== stamp(stamper(b), "A") + + def digestIsDeterministic: Result = + val a = analysisOf( + "AB", + classes = Seq("A" -> "A", "B" -> "B"), + internalDeps = Seq("A" -> "B"), + externalDeps = Seq("A" -> "ext.X", "B" -> "ext.Y"), + libraryDeps = Seq(lib("foo") -> "ext.X", lib("bar") -> "ext.Y"), + ) + // Same stamper instance — exercises the `stamps` cache. + val s = stamper(a) + val d1 = stamp(s, "A") + val d2 = stamp(s, "A") + // Fresh stamper — bypasses the cache entirely. + val d3 = stamp(stamper(a), "A") + Result.all(List(d1 ==== d2, d2 ==== d3)) + + def extraHashesChangesDigest: Result = + val a = analysisOf( + "A", + classes = Seq("A" -> "A"), + externalDeps = Seq("A" -> "ext.X"), + libraryDeps = Seq(lib("foo") -> "ext.X"), + ) + val s = stamper(a) + val d1 = stamp(s, "A", Nil) + val d2 = stamp(s, "A", Seq(Digest.dummy(42L))) + Result.all( + List( + Result.assert(d1.isDefined), + Result.assert(d2.isDefined), + Result.diffNamed("extraHashes should change digest", d1, d2)(_ != _), + ) + ) + + def multipleAnalysesAreWalked: Result = + // Different classes live in different analyses. transitiveStamp must + // walk all analyses to find each class. + val a1 = analysisOf( + "A1", + classes = Seq("X" -> "X"), + externalDeps = Seq("X" -> "ext.Z"), + libraryDeps = Seq(lib("foo") -> "ext.Z"), + ) + val a2 = analysisOf( + "A2", + classes = Seq("Y" -> "Y"), + externalDeps = Seq("Y" -> "ext.W"), + libraryDeps = Seq(lib("bar") -> "ext.W"), + ) + val both = stamper(a1, a2) + Result.all( + List( + Result.assert(stamp(both, "X").isDefined).log("X (only in a1) should stamp"), + Result.assert(stamp(both, "Y").isDefined).log("Y (only in a2) should stamp"), + Result.assert(stamp(stamper(a1), "Y").isEmpty).log("Y must be None without a2"), + Result.assert(stamp(stamper(a2), "X").isEmpty).log("X must be None without a1"), + ) + ) + + def cyclesDoNotLoopForever: Result = + // A -> B and B -> A in internal deps. The `alreadySeen` guard prevents + // infinite recursion. + val a = analysisOf( + "AB", + classes = Seq("A" -> "A", "B" -> "B"), + internalDeps = Seq("A" -> "B", "B" -> "A"), + externalDeps = Seq("A" -> "ext.X", "B" -> "ext.Y"), + libraryDeps = Seq(lib("foo") -> "ext.X", lib("bar") -> "ext.Y"), + ) + Result.assert(stamp(stamper(a), "A").isDefined) + + def transitiveContributionsFlowUp: Result = + // A -> B -> C. Changing C's library entry must change A's digest. + def mk(cLib: VirtualFileRef): Analysis = analysisOf( + "ABC", + classes = Seq("A" -> "A", "B" -> "B", "C" -> "C"), + internalDeps = Seq("A" -> "B", "B" -> "C"), + externalDeps = Seq("C" -> "ext.C"), + libraryDeps = Seq(cLib -> "ext.C"), + ) + val d1 = stamp(stamper(mk(lib("c1"))), "A") + val d2 = stamp(stamper(mk(lib("c2"))), "A") + Result.all( + List( + Result.assert(d1.isDefined), + Result.assert(d2.isDefined), + Result.diffNamed("deep transitive change should reach A", d1, d2)(_ != _), + ) + ) + +end ClassStamperTest From 57e1b84997103276da94df7ced02eb00b2e82843 Mon Sep 17 00:00:00 2001 From: Matt Dziuban Date: Wed, 20 May 2026 10:08:42 -0400 Subject: [PATCH 2/4] Improve performance of `ClassStamper`. - Use `Builder`s to avoid building intermediate collections - Use plain `Set` instead of `SortedSet` - Sorting only needs to happen at the end of the computation in `transitiveStamp` - This is now done with a call to `.toSeq.sorted` --- .../scala/sbt/internal/IncrementalTest.scala | 64 +++++++++++-------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/main/src/main/scala/sbt/internal/IncrementalTest.scala b/main/src/main/scala/sbt/internal/IncrementalTest.scala index 3b13d6e5e..01d227cd9 100644 --- a/main/src/main/scala/sbt/internal/IncrementalTest.scala +++ b/main/src/main/scala/sbt/internal/IncrementalTest.scala @@ -23,7 +23,6 @@ import sbt.util.CacheImplicits import sbt.util.CacheImplicits.given import scala.collection.concurrent import scala.collection.mutable -import scala.collection.SortedSet import xsbti.{ FileConverter, HashedVirtualFileRef, VirtualFileRef } object IncrementalTest: @@ -175,8 +174,7 @@ class ClassStamper private[sbt] ( converter, ) - private val stamps = mutable.Map.empty[String, SortedSet[Digest]] - private val internalStamps = mutable.Map.empty[String, SortedSet[Digest]] + private val stamps = mutable.Map.empty[String, Set[Digest]] // Cached so by-name `analyses0` is only evaluated once private lazy val analyses = analyses0 private val stampVf: VirtualFileRef => Digest = @@ -192,53 +190,65 @@ class ClassStamper private[sbt] ( extraHashes: Seq[Digest], log: Logger, ): Option[Digest] = - val digests = SortedSet(analyses.flatMap(internalStamp(javaClassName, _, Set.empty, log))*) - log.debug(s"test: transitiveStamp($javaClassName, $extraHashes) = $digests") - if digests.nonEmpty then Some(Digest.sha256Hash(digests.toSeq ++ extraHashes*)) + val builder = Set.newBuilder[Digest] + analyses.foreach: analysis => + internalStamp(builder, javaClassName, analysis, Set.empty, log) + val digests = builder.result().toSeq.sorted + // log.debug(s"test: transitiveStamp($javaClassName, $extraHashes) = $digests") + if digests.nonEmpty then Some(Digest.sha256Hash(digests ++ extraHashes*)) else None private def internalStamp( + builder: mutable.Builder[Digest, Set[Digest]], javaClassName: String, analysis: Analysis, alreadySeen: Set[String], log: Logger, - ): SortedSet[Digest] = + ): Unit = import analysis.relations + // log.debug(s"test: internalStamp($javaClassName)") - def internalStamp0(className: String): SortedSet[Digest] = + def internalStamp0(className: String): Unit = + // Use a new builder so we can cache the result in `stamps` + val newBuilder = Set.newBuilder[Digest] + // Zinc doesn't fully track the transitive dependencies - val internalDeps = relations + relations .internalClassDeps(className) - .flatMap: otherCN => - internalStamp(otherCN, analysis, alreadySeen + javaClassName, log) + .foreach: otherCN => + internalStamp(newBuilder, otherCN, analysis, alreadySeen + javaClassName, log) // log.debug(s" internalStamp: internalDeps: $className = $internalDeps") - val internalJarDeps = relations + relations .externalDeps(className) - .flatMap: libClassName => - transitiveStamp(libClassName, Nil, log) - val externalDeps = relations + .foreach: libClassName => + newBuilder ++= transitiveStamp(libClassName, Nil, log) + relations .externalDeps(className) - .flatMap: libClassName => + .foreach: libClassName => relations.libraryClassName .reverse(libClassName) - .map(stampVf) - val classDigests = analysis.apis.internal + .foreach: vf => + newBuilder += stampVf(vf) + analysis.apis.internal .get(className) .toSet - .map: analyzed => - Digest.dummy(37 * (17 + analyzed.transitiveBytecodeHash) + analyzed.bytecodeHash) - val xs = - (internalDeps union internalJarDeps union externalDeps union classDigests) - .to(SortedSet) + .foreach: analyzed => + newBuilder += Digest.dummy( + 37 * (17 + analyzed.transitiveBytecodeHash) + analyzed.bytecodeHash + ) + + val xs = newBuilder.result() if xs.nonEmpty then stamps(className) = xs else () - xs - if alreadySeen.contains(javaClassName) then SortedSet.empty + + builder ++= xs + + if alreadySeen.contains(javaClassName) then () else stamps.get(javaClassName) match - case Some(xs) => xs + case Some(xs) => builder ++= xs case _ => // Note: internalClassDeps uses Scala-encoded class name for companion objects val classNames = relations.productClassName.reverse(javaClassName) - SortedSet(classNames.toSeq*).flatMap(internalStamp0) + classNames.foreach(internalStamp0) end ClassStamper From 4fd24198d050b5b87457ac45b1fee79ea34b3980 Mon Sep 17 00:00:00 2001 From: Matt Dziuban Date: Wed, 20 May 2026 17:56:26 -0400 Subject: [PATCH 3/4] Use a mutable Set for `alreadySeen`. --- main/src/main/scala/sbt/internal/IncrementalTest.scala | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/main/src/main/scala/sbt/internal/IncrementalTest.scala b/main/src/main/scala/sbt/internal/IncrementalTest.scala index 01d227cd9..0a9faaa7b 100644 --- a/main/src/main/scala/sbt/internal/IncrementalTest.scala +++ b/main/src/main/scala/sbt/internal/IncrementalTest.scala @@ -192,7 +192,7 @@ class ClassStamper private[sbt] ( ): Option[Digest] = val builder = Set.newBuilder[Digest] analyses.foreach: analysis => - internalStamp(builder, javaClassName, analysis, Set.empty, log) + internalStamp(builder, javaClassName, analysis, mutable.Set.empty, log) val digests = builder.result().toSeq.sorted // log.debug(s"test: transitiveStamp($javaClassName, $extraHashes) = $digests") if digests.nonEmpty then Some(Digest.sha256Hash(digests ++ extraHashes*)) @@ -202,7 +202,7 @@ class ClassStamper private[sbt] ( builder: mutable.Builder[Digest, Set[Digest]], javaClassName: String, analysis: Analysis, - alreadySeen: Set[String], + alreadySeen: mutable.Set[String], log: Logger, ): Unit = import analysis.relations @@ -216,7 +216,7 @@ class ClassStamper private[sbt] ( relations .internalClassDeps(className) .foreach: otherCN => - internalStamp(newBuilder, otherCN, analysis, alreadySeen + javaClassName, log) + internalStamp(newBuilder, otherCN, analysis, alreadySeen, log) // log.debug(s" internalStamp: internalDeps: $className = $internalDeps") relations .externalDeps(className) @@ -247,7 +247,8 @@ class ClassStamper private[sbt] ( else stamps.get(javaClassName) match case Some(xs) => builder ++= xs - case _ => + case _ => + alreadySeen += javaClassName // Note: internalClassDeps uses Scala-encoded class name for companion objects val classNames = relations.productClassName.reverse(javaClassName) classNames.foreach(internalStamp0) From ee192ead15d5f746fa73665418beaee222a9deae Mon Sep 17 00:00:00 2001 From: Matt Dziuban Date: Fri, 22 May 2026 10:20:16 -0400 Subject: [PATCH 4/4] Integrate changes from https://github.com/sbt/sbt/pull/9257. --- .../scala/sbt/internal/IncrementalTest.scala | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/main/src/main/scala/sbt/internal/IncrementalTest.scala b/main/src/main/scala/sbt/internal/IncrementalTest.scala index 0a9faaa7b..81462fab0 100644 --- a/main/src/main/scala/sbt/internal/IncrementalTest.scala +++ b/main/src/main/scala/sbt/internal/IncrementalTest.scala @@ -190,14 +190,20 @@ class ClassStamper private[sbt] ( extraHashes: Seq[Digest], log: Logger, ): Option[Digest] = - val builder = Set.newBuilder[Digest] - analyses.foreach: analysis => - internalStamp(builder, javaClassName, analysis, mutable.Set.empty, log) - val digests = builder.result().toSeq.sorted - // log.debug(s"test: transitiveStamp($javaClassName, $extraHashes) = $digests") - if digests.nonEmpty then Some(Digest.sha256Hash(digests ++ extraHashes*)) + val digests = transitiveStamps(javaClassName, extraHashes, log) + if digests.nonEmpty then Some(Digest.sha256Hash(digests*)) else None + private def transitiveStamps( + javaClassName: String, + extraHashes: Seq[Digest], + log: Logger, + ): Seq[Digest] = + val builder = Set.newBuilder[Digest] + analyses.foreach(internalStamp(builder, javaClassName, _, mutable.Set.empty, log)) + val digests = builder.result().toSeq.sorted + digests ++ extraHashes + private def internalStamp( builder: mutable.Builder[Digest, Set[Digest]], javaClassName: String, @@ -221,7 +227,7 @@ class ClassStamper private[sbt] ( relations .externalDeps(className) .foreach: libClassName => - newBuilder ++= transitiveStamp(libClassName, Nil, log) + newBuilder ++= transitiveStamps(libClassName, Nil, log) relations .externalDeps(className) .foreach: libClassName =>