This commit is contained in:
Matt Dziuban 2026-06-01 00:19:29 -04:00 committed by GitHub
commit aa89414823
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 425 additions and 34 deletions

View File

@ -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:
@ -160,15 +159,24 @@ 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,
):
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 }
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, Set[Digest]]
// Cached so by-name `analyses0` is only evaluated once
private lazy val analyses = analyses0
private val stampVf: VirtualFileRef => Digest =
CacheImplicits.virtualFileRefToDigest(_)(converter)
@ -182,53 +190,72 @@ class ClassStamper(
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 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,
analysis: Analysis,
alreadySeen: Set[String],
alreadySeen: mutable.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, 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 ++= transitiveStamps(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 _ =>
case Some(xs) => builder ++= xs
case _ =>
alreadySeen += javaClassName
// 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

View File

@ -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