org.gradle.internal.execution.impl.steps.IncrementalExecutionTest.groovy Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of gradle-api Show documentation
Show all versions of gradle-api Show documentation
Gradle 6.9.1 API redistribution.
/*
* Copyright 2018 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.gradle.internal.execution.impl.steps
import com.google.common.collect.ImmutableList
import com.google.common.collect.ImmutableSortedMap
import com.google.common.collect.Iterables
import org.gradle.api.file.FileCollection
import org.gradle.api.internal.cache.StringInterner
import org.gradle.api.internal.file.TestFiles
import org.gradle.api.internal.file.collections.ImmutableFileCollection
import org.gradle.api.internal.model.NamedObjectInstantiator
import org.gradle.caching.internal.CacheableEntity
import org.gradle.caching.internal.origin.OriginMetadata
import org.gradle.internal.classloader.ClassLoaderHierarchyHasher
import org.gradle.internal.execution.CacheHandler
import org.gradle.internal.execution.ExecutionException
import org.gradle.internal.execution.ExecutionOutcome
import org.gradle.internal.execution.OutputChangeListener
import org.gradle.internal.execution.Result
import org.gradle.internal.execution.TestExecutionHistoryStore
import org.gradle.internal.execution.TestOutputFilesRepository
import org.gradle.internal.execution.UnitOfWork
import org.gradle.internal.execution.WorkExecutor
import org.gradle.internal.execution.history.changes.DefaultExecutionStateChanges
import org.gradle.internal.execution.history.changes.ExecutionStateChanges
import org.gradle.internal.execution.history.impl.DefaultBeforeExecutionState
import org.gradle.internal.execution.impl.DefaultWorkExecutor
import org.gradle.internal.file.TreeType
import org.gradle.internal.fingerprint.CurrentFileCollectionFingerprint
import org.gradle.internal.fingerprint.impl.AbsolutePathFileCollectionFingerprinter
import org.gradle.internal.fingerprint.impl.OutputFileCollectionFingerprinter
import org.gradle.internal.hash.HashCode
import org.gradle.internal.hash.TestFileHasher
import org.gradle.internal.id.UniqueId
import org.gradle.internal.scopeids.id.BuildInvocationScopeId
import org.gradle.internal.snapshot.ValueSnapshot
import org.gradle.internal.snapshot.WellKnownFileLocations
import org.gradle.internal.snapshot.impl.DefaultFileSystemMirror
import org.gradle.internal.snapshot.impl.DefaultFileSystemSnapshotter
import org.gradle.internal.snapshot.impl.DefaultValueSnapshotter
import org.gradle.internal.snapshot.impl.ImplementationSnapshot
import org.gradle.test.fixtures.file.TestFile
import org.gradle.test.fixtures.file.TestNameTestDirectoryProvider
import org.gradle.testing.internal.util.Specification
import org.junit.Rule
import java.time.Duration
import java.util.function.BooleanSupplier
import static org.gradle.internal.execution.ExecutionOutcome.EXECUTED
import static org.gradle.internal.execution.ExecutionOutcome.UP_TO_DATE
class IncrementalExecutionTest extends Specification {
@Rule
final TestNameTestDirectoryProvider temporaryFolder = TestNameTestDirectoryProvider.newInstance()
def fileHasher = new TestFileHasher()
def fileSystemMirror = new DefaultFileSystemMirror(Stub(WellKnownFileLocations))
def snapshotter = new DefaultFileSystemSnapshotter(fileHasher, new StringInterner(), TestFiles.fileSystem(), fileSystemMirror)
def fingerprinter = new AbsolutePathFileCollectionFingerprinter(new StringInterner(), snapshotter)
def outputFingerprinter = new OutputFileCollectionFingerprinter(new StringInterner(), snapshotter)
def executionHistoryStore = new TestExecutionHistoryStore()
def outputChangeListener = new OutputChangeListener() {
@Override
void beforeOutputChange() {
fileSystemMirror.beforeOutputChange()
}
@Override
void beforeOutputChange(Iterable affectedOutputPaths) {
fileSystemMirror.beforeOutputChange(affectedOutputPaths)
}
}
def outputFilesRepository = new TestOutputFilesRepository()
def buildInvocationScopeId = new BuildInvocationScopeId(UniqueId.generate())
def classloaderHierarchyHasher = new ClassLoaderHierarchyHasher() {
@Override
HashCode getClassLoaderHash(ClassLoader classLoader) {
return HashCode.fromInt(1234)
}
}
def valueSnapshotter = new DefaultValueSnapshotter(classloaderHierarchyHasher, new NamedObjectInstantiator())
final outputFile = temporaryFolder.file("output-file")
final outputDir = temporaryFolder.file("output-dir")
final outputDirFile = outputDir.file("some-file")
final outputDirFile2 = outputDir.file("some-file-2")
final emptyOutputDir = temporaryFolder.file("empty-output-dir")
final missingOutputFile = temporaryFolder.file("missing-output-file")
final missingOutputDir = temporaryFolder.file("missing-output-dir")
final inputFile = temporaryFolder.createFile("input-file")
final inputDir = temporaryFolder.createDir("input-dir")
final inputDirFile = inputDir.file("input-file2").createFile()
final missingInputFile = temporaryFolder.file("missing-input-file")
final inputFiles = [file: [inputFile], dir: [inputDir], missingFile: [missingInputFile]]
final outputFiles = [file: [outputFile], missingFile: [missingOutputFile]]
final outputDirs = [emptyDir: [emptyOutputDir], dir: [outputDir], missingDir: [missingOutputDir]]
final createFiles = [outputFile, outputDirFile, outputDirFile2] as Set
def unitOfWork = builder.build()
WorkExecutor getExecutor() {
new DefaultWorkExecutor(
new SkipUpToDateStep(
new StoreSnapshotsStep(outputFilesRepository,
new SnapshotOutputStep(buildInvocationScopeId.getId(),
new CreateOutputsStep(
new CatchExceptionStep(
new ExecuteStep(outputChangeListener)
)
)
)
)
)
)
}
def "outputs are created"() {
def unitOfWork = builder.withOutputDirs(
dir: [file("outDir")],
dirs: [file("outDir1"), file("outDir2")],
).withOutputFiles(
"file": [file("parent/outFile")],
"files": [file("parent1/outFile"), file("parent2/outputFile1"), file("parent2/outputFile2")],
).withWork { ->
true
}.build()
when:
def result = execute(unitOfWork)
then:
result.outcome.get() == EXECUTED
!result.reused
def allDirs = ["outDir", "outDir1", "outDir2"].collect { file(it) }
def allFiles = ["parent/outFile", "parent1/outFile1", "parent2/outFile1", "parent2/outFile2"].collect { file(it) }
allDirs.each {
assert it.isDirectory()
}
allFiles.each {
assert it.parentFile.isDirectory()
assert !it.exists()
}
}
def "output snapshots are stored"() {
when:
def result = execute(unitOfWork)
then:
result.outcome.get() == EXECUTED
!result.reused
result.finalOutputs.keySet() == ["dir", "emptyDir", "file", "missingDir", "missingFile"] as Set
result.finalOutputs["dir"].rootHashes.size() == 1
result.originMetadata.buildInvocationId == buildInvocationScopeId.id
def afterExecution = Iterables.getOnlyElement(executionHistoryStore.executionHistory.values())
afterExecution.originMetadata.buildInvocationId == buildInvocationScopeId.id
afterExecution.outputFileProperties.values()*.rootHashes == result.finalOutputs.values()*.rootHashes
}
def "work unit is up-to-date if nothing changes"() {
when:
def result = execute(unitOfWork)
then:
result.outcome.get() == EXECUTED
!result.reused
def origin = result.originMetadata.buildInvocationId
def finalOutputs = result.finalOutputs
when:
buildInvocationScopeId = new BuildInvocationScopeId(UniqueId.generate())
result = upToDate(builder.withWork { ->
throw new IllegalStateException("Must not be executed")
}.build())
then:
result.outcome.get() == UP_TO_DATE
result.reused
result.originMetadata.buildInvocationId == origin
result.originMetadata.buildInvocationId != buildInvocationScopeId.id
result.finalOutputs.values()*.rootHashes == finalOutputs.values()*.rootHashes
}
def "out-of-date for an output file change"() {
when:
def result = execute(unitOfWork)
then:
result.outcome.get() == EXECUTED
!result.reused
def origin = result.originMetadata.buildInvocationId
when:
buildInvocationScopeId = new BuildInvocationScopeId(UniqueId.generate())
outputFile.text = "outdated"
result = outOfDate(builder.build(), outputFilesChanged(file: [outputFile]))
then:
result.outcome.get() == EXECUTED
!result.reused
result.originMetadata.buildInvocationId == buildInvocationScopeId.id
result.originMetadata.buildInvocationId != origin
}
def "failed executions are never up-to-date"() {
def failure = new RuntimeException()
when:
def result = execute(builder.withWork { ->
throw failure
}.build())
then:
result.outcome.failure.get() instanceof ExecutionException
result.outcome.failure.get().cause == failure
!result.reused
def origin = result.originMetadata.buildInvocationId
when:
buildInvocationScopeId = new BuildInvocationScopeId(UniqueId.generate())
result = outOfDate(builder.build(), "Task has failed previously.")
then:
result.outcome.get() == EXECUTED
!result.reused
result.originMetadata.buildInvocationId == buildInvocationScopeId.id
result.originMetadata.buildInvocationId != origin
}
def "out of date when no history"() {
when:
def result = execute(unitOfWork)
then:
result.outcome.get() == EXECUTED
!result.reused
result.outOfDateReasons == ["No history is available."]
}
def "out of date when output file removed"() {
given:
execute(unitOfWork)
when:
outputFile.delete()
def result = execute(unitOfWork)
then:
result.outcome.get() == EXECUTED
!result.reused
result.outOfDateReasons == ["Output property 'file' file ${outputFile.absolutePath} has been removed."]
}
def "out of date when output file in output dir removed"() {
given:
execute(unitOfWork)
when:
outputDirFile.delete()
def result = execute(unitOfWork)
then:
result.outcome.get() == EXECUTED
!result.reused
result.outOfDateReasons == ["Output property 'dir' file ${outputDirFile.absolutePath} has been removed."]
}
def "out of date when output file has changed type"() {
given:
execute(unitOfWork)
when:
outputFile.delete()
outputFile.createDir()
def result = execute(unitOfWork)
then:
result.outcome.failure.get().message == "Execution failed for Test unit of work."
!result.reused
result.outOfDateReasons == ["Output property 'file' file ${outputFile.absolutePath} has changed."]
}
def "out of date when any file in output dir has changed type"() {
given:
execute(unitOfWork)
when:
outputDirFile.delete()
outputDirFile.createDir()
def result = execute(unitOfWork)
then:
result.outcome.failure.get().message == "Execution failed for Test unit of work."
!result.reused
result.outOfDateReasons == ["Output property 'dir' file ${outputDirFile.absolutePath} has changed."]
}
def "out of date when any output file has changed contents"() {
given:
execute(unitOfWork)
when:
outputFile << "new content"
def result = execute(unitOfWork)
then:
result.outcome.get() == EXECUTED
!result.reused
result.outOfDateReasons == ["Output property 'file' file ${outputFile.absolutePath} has changed."]
}
def "out of date when any file in output dir has changed contents"() {
given:
execute(unitOfWork)
when:
outputDirFile << "new content"
def result = execute(unitOfWork)
then:
result.outcome.get() == EXECUTED
!result.reused
result.outOfDateReasons == ["Output property 'dir' file ${outputDirFile.absolutePath} has changed."]
}
def "out-of-date when any output files properties are added"() {
when:
execute(unitOfWork)
def outputFilesAddedUnitOfWork = builder.withOutputFiles(*:outputFiles, newFile: [temporaryFolder.createFile("output-file-2")]).build()
then:
outOfDate(outputFilesAddedUnitOfWork, "Output property 'newFile' has been added for ${outputFilesAddedUnitOfWork.displayName}")
}
def "out-of-date when any output file properties are removed"() {
given:
execute(unitOfWork)
when:
outputFiles.removeAll { it.key == "file"}
def outputFilesRemovedUnitOfWork = builder.withOutputFiles(outputFiles).build()
def result = execute(outputFilesRemovedUnitOfWork)
then:
result.outcome.get() == EXECUTED
!result.reused
result.outOfDateReasons == ["Output property 'file' has been removed for ${outputFilesRemovedUnitOfWork.displayName}"]
}
def "out-of-date when implementation changes"() {
expect:
execute(unitOfWork)
outOfDate(
builder.withImplementation(ImplementationSnapshot.of("DifferentType", HashCode.fromInt(1234))).build(),
"The type of ${unitOfWork.displayName} has changed from 'org.gradle.internal.execution.UnitOfWork' to 'DifferentType'."
)
}
def "out-of-date when any input files are added"() {
final addedFile = temporaryFolder.createFile("other-input")
when:
execute(unitOfWork)
def inputFilesAdded = builder.withInputFiles(file: [inputFile, addedFile], dir: [inputDir], missingFile: [missingInputFile]).build()
then:
outOfDate(inputFilesAdded, filesAdded(file: [addedFile]))
}
def "out-of-date when any input file is removed"() {
when:
execute(unitOfWork)
def inputFilesRemovedUnitOfWork = builder.withInputFiles(file: [inputFile], dir: [], missingFile: [missingInputFile]).build()
then:
outOfDate(inputFilesRemovedUnitOfWork, inputFilesRemoved(dir: [inputDir, inputDirFile]))
}
def "out-of-date when any input file changes"() {
given:
execute(unitOfWork)
when:
inputFile.write("some new content")
then:
outOfDate(unitOfWork, inputFilesChanged(file: [inputFile]))
}
def "out-of-date when any input file type changes"() {
given:
execute(unitOfWork)
when:
inputFile.delete()
inputFile.createDir()
then:
outOfDate(unitOfWork, inputFilesChanged(file: [inputFile]))
}
def "out-of-date when any input file no longer exists"() {
given:
execute(unitOfWork)
when:
inputFile.delete()
then:
outOfDate(unitOfWork, inputFilesRemoved(file: [inputFile]))
}
def "out-of-date when any input file did not exist and now does"() {
given:
inputFile.delete()
execute(unitOfWork)
when:
inputFile.createNewFile()
then:
outOfDate(unitOfWork, filesAdded(file: [inputFile]))
}
def "out-of-date when any file is created in input dir"() {
given:
execute(unitOfWork)
when:
def file = inputDir.file("other-file").createFile()
then:
outOfDate(unitOfWork, filesAdded(dir: [file]))
}
def "out-of-date when any file deleted from input dir"() {
given:
execute(unitOfWork)
when:
inputDirFile.delete()
then:
outOfDate(unitOfWork, inputFilesRemoved(dir: [inputDirFile]))
}
def "out-of-date when any file in input dir changes"() {
given:
execute(unitOfWork)
when:
inputDirFile.writelns("new content")
then:
outOfDate(unitOfWork, inputFilesChanged(dir: [inputDirFile]))
}
def "out-of-date when any file in input dir changes type"() {
given:
execute(unitOfWork)
when:
inputDirFile.delete()
inputDirFile.mkdir()
then:
outOfDate(unitOfWork, inputFilesChanged(dir: [inputDirFile]))
}
def "out-of-date when any input property value changed"() {
when:
execute(builder.withProperty("prop", "original value").build())
def inputPropertiesChanged = builder.withProperty("prop", "new value").build()
then:
outOfDate(inputPropertiesChanged, "Value of input property 'prop' has changed for ${inputPropertiesChanged.displayName}")
}
def "input property value can be null"() {
when:
def unitOfWork = builder.withProperty("prop", null).build()
execute(unitOfWork)
then:
upToDate(unitOfWork)
}
def "out-of-date when any input property added"() {
when:
execute(unitOfWork)
def addedProperty = builder.withProperty("prop2", "value").build()
then:
outOfDate(addedProperty, "Input property 'prop2' has been added for ${addedProperty.displayName}")
}
def "out-of-date when any input property removed"() {
given:
execute(builder.withProperty("prop2", "value").build())
expect:
outOfDate(unitOfWork, "Input property 'prop2' has been removed for ${unitOfWork.displayName}")
}
def "up to date when output file which did not exist now exists"() {
given:
execute(unitOfWork)
when:
missingOutputFile.touch()
then:
upToDate(unitOfWork)
}
def "up to date when output dir which was empty is no longer empty"() {
given:
execute(unitOfWork)
when:
emptyOutputDir.file("some-file").touch()
then:
upToDate(unitOfWork)
}
def "up to date when no inputs"() {
when:
def noInputsTask = builder.withoutInputFiles().build()
execute(noInputsTask)
then:
upToDate noInputsTask
when:
outputDirFile.delete()
then:
outOfDate(noInputsTask, outputFilesRemoved(dir: [outputDirFile]))
}
def "up to date when task has no output files"() {
when:
def noOutputs = builder.withOutputFiles([:]).build()
execute(noOutputs)
then:
upToDate noOutputs
}
List inputFilesRemoved(Map> removedFiles) {
filesRemoved('Input', removedFiles)
}
List outputFilesRemoved(Map> removedFiles) {
filesRemoved('Output', removedFiles)
}
List filesRemoved(String type, Map> removedFiles) {
removedFiles.collectMany { propertyName, files ->
files.collect { file ->
"${type} property '${propertyName}' file ${file.absolutePath} has been removed.".toString()
}
}
}
List outputFilesChanged(Map> removedFiles) {
filesChanged('Output', removedFiles)
}
List inputFilesChanged(Map> changedFiles) {
filesChanged("Input", changedFiles)
}
List filesChanged(String type, Map> changedFiles) {
changedFiles.collectMany { propertyName, files ->
files.collect { file ->
"${type} property '${propertyName}' file ${file.absolutePath} has changed.".toString()
}
}
}
List filesAdded(Map> addedFiles) {
addedFiles.collectMany { propertyName, files ->
files.collect { file ->
"Input property '${propertyName}' file ${file.absolutePath} has been added.".toString()
}
}
}
UpToDateResult outOfDate(UnitOfWork unitOfWork, String... expectedReasons) {
return outOfDate(unitOfWork, ImmutableList.copyOf(expectedReasons))
}
UpToDateResult outOfDate(UnitOfWork unitOfWork, List expectedReasons) {
def result = execute(unitOfWork)
assert result.outcome.get() == EXECUTED
assert !result.reused
assert result.outOfDateReasons == expectedReasons
return result
}
UpToDateResult upToDate(UnitOfWork unitOfWork) {
def result = execute(unitOfWork)
assert result.outcome.get() == UP_TO_DATE
return result
}
UpToDateResult execute(UnitOfWork unitOfWork) {
fileSystemMirror.beforeBuildFinished()
executor.execute(unitOfWork)
}
private TestFile file(Object... path) {
return temporaryFolder.file(path)
}
static class OutputPropertySpec {
FileCollection roots
TreeType treeType
OutputPropertySpec(Iterable roots, TreeType treeType) {
this.treeType = treeType
this.roots = ImmutableFileCollection.of(roots)
}
}
static OutputPropertySpec outputDirectorySpec(File... dirs) {
return new OutputPropertySpec(ImmutableList.copyOf(dirs), TreeType.DIRECTORY)
}
static OutputPropertySpec outputFileSpec(File... files) {
return new OutputPropertySpec(ImmutableList.copyOf(files), TreeType.FILE)
}
UnitOfWorkBuilder getBuilder() {
new UnitOfWorkBuilder()
}
class UnitOfWorkBuilder {
private BooleanSupplier work = { ->
create.each { it ->
it.createFile()
}
return true
}
private Map inputProperties = [prop: "value"]
private Map> inputs = inputFiles
private Map> outputFiles = IncrementalExecutionTest.this.outputFiles
private Map> outputDirs = IncrementalExecutionTest.this.outputDirs
private Collection extends TestFile> create = createFiles
private ImplementationSnapshot implementation = ImplementationSnapshot.of(UnitOfWork.name, HashCode.fromInt(1234))
private
UnitOfWorkBuilder withWork(BooleanSupplier closure) {
work = closure
return this
}
UnitOfWorkBuilder withInputFiles(Map> files) {
this.inputs = files
return this
}
UnitOfWorkBuilder withoutInputFiles() {
this.inputs = [:]
return this
}
UnitOfWorkBuilder withoutInputProperties() {
this.inputProperties = [:]
return this
}
UnitOfWorkBuilder withOutputFiles(File... outputFiles) {
return withOutputFiles(defaultFiles: Arrays.asList(outputFiles))
}
UnitOfWorkBuilder withOutputFiles(Map> files) {
this.outputFiles = files
return this
}
UnitOfWorkBuilder withOutputDirs(File... outputDirs) {
return withOutputDirs(defaultDir: Arrays.asList(outputDirs))
}
UnitOfWorkBuilder withOutputDirs(Map> dirs) {
this.outputDirs = dirs
return this
}
UnitOfWorkBuilder createsFiles(TestFile... outputFiles) {
create = Arrays.asList(outputFiles)
return this
}
UnitOfWorkBuilder withImplementation(ImplementationSnapshot implementation) {
this.implementation = implementation
return this
}
UnitOfWorkBuilder withProperty(String name, Object value) {
inputProperties.put(name, value)
return this
}
UnitOfWork build() {
def outputFileSpecs = outputFiles.collectEntries { key, value -> [(key): outputFileSpec(*value)] }
def outputDirSpecs = outputDirs.collectEntries { key, value -> [(key): outputDirectorySpec(*value)]}
return new UnitOfWork() {
private final Map outputs = outputFileSpecs + outputDirSpecs
private final ImplementationSnapshot implementationSnapshot = implementation
private final ImmutableList additionalImplementationSnapshots = ImmutableList.of()
Optional changes
boolean executed
@Override
ExecutionOutcome execute() {
executed = true
return work.asBoolean ? EXECUTED : UP_TO_DATE
}
@Override
Optional getTimeout() {
throw new UnsupportedOperationException()
}
@Override
void visitOutputProperties(UnitOfWork.OutputPropertyVisitor visitor) {
outputs.forEach { name, spec ->
visitor.visitOutputProperty(name, spec.treeType, spec.roots)
}
}
@Override
long markExecutionTime() {
0
}
@Override
void visitLocalState(CacheableEntity.LocalStateVisitor visitor) {
throw new UnsupportedOperationException()
}
@Override
void outputsRemovedAfterFailureToLoadFromCache() {
throw new UnsupportedOperationException()
}
@Override
CacheHandler createCacheHandler() {
throw new UnsupportedOperationException()
}
@Override
void persistResult(ImmutableSortedMap finalOutputs, boolean successful, OriginMetadata originMetadata) {
executionHistoryStore.store(
getIdentity(),
originMetadata,
implementationSnapshot,
additionalImplementationSnapshots,
snapshotInputProperties(),
snapshotInputFiles(),
finalOutputs,
successful
)
}
@Override
Optional extends Iterable> getChangingOutputs() {
Optional.empty()
}
@Override
String getIdentity() {
"myId"
}
@Override
void visitOutputTrees(CacheableEntity.CacheableTreeVisitor visitor) {
throw new UnsupportedOperationException()
}
@Override
String getDisplayName() {
"Test unit of work"
}
ImplementationSnapshot implementationSnapshot = implementation
@Override
Optional getChangesSincePreviousExecution() {
changes = executionHistoryStore.load(getIdentity()).map { previous ->
def outputsBefore = snapshotOutputs()
def beforeExecutionState = new DefaultBeforeExecutionState(implementationSnapshot, additionalImplementationSnapshots, snapshotInputProperties(), snapshotInputFiles(), outputsBefore)
return new DefaultExecutionStateChanges(previous, beforeExecutionState, this)
}
}
private ImmutableSortedMap snapshotInputProperties() {
def builder = ImmutableSortedMap.naturalOrder()
inputProperties.each { propertyName, value ->
builder.put(propertyName, valueSnapshotter.snapshot(value))
}
return builder.build()
}
private ImmutableSortedMap snapshotInputFiles() {
def builder = ImmutableSortedMap.naturalOrder()
inputs.each { propertyName, value ->
builder.put(propertyName, fingerprinter.fingerprint(ImmutableFileCollection.of(value)))
}
return builder.build()
}
@Override
ImmutableSortedMap snapshotAfterOutputsGenerated() {
snapshotOutputs()
}
private ImmutableSortedMap snapshotOutputs() {
def builder = ImmutableSortedMap.naturalOrder()
outputs.each { propertyName, spec ->
builder.put(propertyName, outputFingerprinter.fingerprint(spec.roots))
}
return builder.build()
}
}
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy