diff --git a/src/main/groovy/nebula/plugin/dependencyverifier/DependencyResolutionVerifier.groovy b/src/main/groovy/nebula/plugin/dependencyverifier/DependencyResolutionVerifier.groovy deleted file mode 100644 index 8aeab373..00000000 --- a/src/main/groovy/nebula/plugin/dependencyverifier/DependencyResolutionVerifier.groovy +++ /dev/null @@ -1,271 +0,0 @@ -/** - * - * Copyright 2019 Netflix, Inc. - * - * 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 nebula.plugin.dependencyverifier - -import nebula.plugin.dependencylock.utils.ConfigurationFilters -import nebula.plugin.dependencylock.utils.GradleVersionUtils -import nebula.plugin.dependencyverifier.exceptions.DependencyResolutionException -import org.gradle.BuildResult -import org.gradle.api.BuildCancelledException -import org.gradle.api.Project -import org.gradle.api.Task -import org.gradle.api.artifacts.Configuration -import org.gradle.api.artifacts.ResolveException -import org.gradle.api.execution.TaskExecutionListener -import org.gradle.api.logging.Logger -import org.gradle.api.logging.Logging -import org.gradle.api.tasks.TaskState -import org.gradle.internal.exceptions.DefaultMultiCauseException -import org.gradle.internal.locking.LockOutOfDateException -import org.gradle.internal.resolve.ArtifactResolveException -import org.gradle.internal.resolve.ModuleVersionNotFoundException -import org.gradle.internal.resolve.ModuleVersionResolveException - -class DependencyResolutionVerifier { - private static final Logger LOGGER = Logging.getLogger(DependencyResolutionVerifier.class) - private static final String UNRESOLVED_DEPENDENCIES_FAIL_THE_BUILD = 'dependencyResolutionVerifier.unresolvedDependenciesFailTheBuild' - private static final String CONFIGURATIONS_TO_EXCLUDE = 'dependencyResolutionVerifier.configurationsToExclude' - - static void verifySuccessfulResolution(Project project) { - def extension = project.rootProject.extensions.findByType(DependencyResolutionVerifierExtension) - Map> failedDepsByConf = new HashMap>() - Set lockedDepsOutOfDate = new HashSet<>() - - boolean parallelProjectExecutionEnabled = project.gradle.startParameter.isParallelProjectExecutionEnabled() - - boolean providedErrorMessageForThisProject = false - - Closure logOrThrowOnFailedDependencies = { from -> - if (failedDepsByConf.size() != 0 || lockedDepsOutOfDate.size() != 0) { - List message = new ArrayList<>() - List debugMessage = new ArrayList<>() - List depsMissingVersions = new ArrayList<>() - try { - if (failedDepsByConf.size() > 0) { - message.add("Failed to resolve the following dependencies:") - } - failedDepsByConf - .sort() - .eachWithIndex { kv, index -> - def dep = kv.key - def failedConfs = kv.value - message.add(" ${index + 1}. Failed to resolve '$dep' for project '${project.name}'") - debugMessage.add("Failed to resolve $dep on:") - failedConfs - .sort { a, b -> a.name <=> b.name } - .each { failedConf -> - debugMessage.add(" - $failedConf") - } - if (dep.split(':').size() < 3) { - depsMissingVersions.add(dep) - } - } - - if (lockedDepsOutOfDate.size() > 0) { - message.add('Resolved dependencies were missing from the lock state:') - } - lockedDepsOutOfDate - .sort() - .eachWithIndex { outOfDateMessage, index -> - message.add(" ${index + 1}. $outOfDateMessage for project '${project.name}'") - } - - if (depsMissingVersions.size() > 0) { - message.add("The following dependencies are missing a version: ${depsMissingVersions.join(', ')}\n" + - "Please add a version to fix this. If you have been using a BOM, perhaps these dependencies are no longer managed. \n" - + extension.missingVersionsMessageAddition) - } - - } catch (Exception e) { - throw new BuildCancelledException("Error creating message regarding failed dependencies", e) - } - - providedErrorMessageForThisProject = true - debugMessage.add("Dependency resolution verification triggered from $from") - - Boolean unresolvedDependenciesShouldFailTheBuild = project.hasProperty(UNRESOLVED_DEPENDENCIES_FAIL_THE_BUILD) - ? (project.property(UNRESOLVED_DEPENDENCIES_FAIL_THE_BUILD) as String).toBoolean() - : (extension.shouldFailTheBuild as String).toBoolean() - if (unresolvedDependenciesShouldFailTheBuild) { - LOGGER.debug(debugMessage.join('\n')) - throw new DependencyResolutionException(message.join('\n')) - } else { - LOGGER.debug(debugMessage.join('\n')) - LOGGER.warn(message.join('\n')) - } - } - } - - project.gradle.buildFinished() { buildResult -> - assert buildResult instanceof BuildResult - boolean buildFailed = buildResult.failure != null - if (!providedErrorMessageForThisProject && buildFailed) { - def failureCause = buildResult.failure?.cause?.cause - if (failureCause == null || !(failureCause instanceof DefaultMultiCauseException)) { - return - } - def moduleVersionNotFoundCauses = failureCause.causes.findAll { - it.class == ModuleVersionNotFoundException - } - if (moduleVersionNotFoundCauses.size() > 0) { - String buildResultFailureMessage = failureCause.message - def split = buildResultFailureMessage.split(':') - String projectNameFromFailure = '' - if (split.size() == 3) { - projectNameFromFailure = split[1] - } - if (project.name == projectNameFromFailure) { - LOGGER.debug("Starting dependency resolution verification after the build has completed: ${buildResultFailureMessage}") - - Configuration conf = null - try { - def confName = buildResultFailureMessage.replace('.', '').split('for ')[1] - conf = project.configurations - .findAll { it.toString() == confName } - .first() - LOGGER.debug("Found $conf from $confName") - } catch (Exception e) { - throw new BuildCancelledException("Error finding configuration associated with build failure from '${buildResultFailureMessage}'", e) - } - - moduleVersionNotFoundCauses.each { it -> - def dep = it.selector.toString() - if (failedDepsByConf.containsKey(dep)) { - failedDepsByConf.get(dep).add(conf as Configuration) - } else { - Set failedConfs = new HashSet() - failedConfs.add(conf as Configuration) - - failedDepsByConf.put(dep, failedConfs) - } - } - - logOrThrowOnFailedDependencies("buildFinished event") - } - } - } - } - - project.gradle.taskGraph.whenReady { taskGraph -> - LinkedList tasks = GradleVersionUtils.currentGradleVersionIsLessThan("5.0") - ? taskGraph.taskExecutionPlan.executionQueue // the method name before Gradle 5.0 - : taskGraph.executionPlan.executionQueue // the method name as of Gradle 5.0 - - List safeTasks - if (parallelProjectExecutionEnabled) { - safeTasks = tasks - .groupBy { task -> - GradleVersionUtils.currentGradleVersionIsLessThan("6.0") - ? task.project // the method name before Gradle 6.0 - : task.owningProject // the method name as of Gradle 6.0 - } - .find { proj, tasksForProj -> proj == project } - ?.value - ?.findAll { it -> it.metaClass.getMetaMethod('getTask') != null } - ?.collect { it -> it.task } - } else { - safeTasks = tasks - .findAll { it -> it.metaClass.getMetaMethod('getTask') != null } - .collect { it -> it.task } - } - - Map tasksGroupedByTaskIdentityAcrossProjects = tasks.groupBy { task -> task.toString().split(':').last() } - - if (!safeTasks) { - return - } - - taskGraph.addTaskExecutionListener(new TaskExecutionListener() { - @Override - void beforeExecute(Task task) { - //DO NOTHING - } - - @Override - void afterExecute(Task task, TaskState taskState) { - boolean taskIsSafeToAccess = safeTasks.contains(task) - boolean taskIsNotExcluded = !extension.tasksToExclude.contains(task.name) - - if (taskIsSafeToAccess && taskIsNotExcluded && !providedErrorMessageForThisProject) { - String simpleTaskName = task.toString().replace("'", '').split(':').last() - Task lastTaskEvaluatedWithSameName = tasksGroupedByTaskIdentityAcrossProjects - .get(simpleTaskName) - .last() - ?.task - - boolean lastChanceToThrowExceptionWithTaskOfThisIdentity - if (parallelProjectExecutionEnabled) { - lastChanceToThrowExceptionWithTaskOfThisIdentity = true - } else { - lastChanceToThrowExceptionWithTaskOfThisIdentity = task.path == lastTaskEvaluatedWithSameName.path - } - - boolean taskHasFailed = taskState.failure - - if (lastChanceToThrowExceptionWithTaskOfThisIdentity || taskHasFailed) { - Set configurationsToExclude = project.hasProperty(CONFIGURATIONS_TO_EXCLUDE) - ? (project.property(CONFIGURATIONS_TO_EXCLUDE) as String).split(",") as Set - : extension.configurationsToExclude - - project.configurations.matching { // returns a live collection - assert it instanceof Configuration - configurationIsResolvedAndMatches(it, configurationsToExclude) - }.all { conf -> - assert conf instanceof Configuration - - LOGGER.debug("$conf in ${project.name} has state ${conf.state}. Starting dependency resolution verification.") - try { - conf.resolvedConfiguration.resolvedArtifacts - } catch (ResolveException | ModuleVersionResolveException | ArtifactResolveException e) { - e.causes.each { - if (LockOutOfDateException.class == it.class) { - lockedDepsOutOfDate.add(it.getMessage()) - } else { - String dep = it.selector.toString() - if (failedDepsByConf.containsKey(dep)) { - failedDepsByConf.get(dep).add(conf as Configuration) - } else { - Set failedConfs = new HashSet() - failedConfs.add(conf as Configuration) - - failedDepsByConf.put(dep, failedConfs) - } - } - } - } - } - - logOrThrowOnFailedDependencies("${task}") - } - } - } - } - - ) - } - } - - private static boolean configurationIsResolvedAndMatches(Configuration conf, Set configurationsToExclude) { - return (conf as Configuration).state != Configuration.State.UNRESOLVED && - // the configurations `incrementalScalaAnalysisFor_x_` are resolvable only from a scala context - !(conf as Configuration).name.startsWith('incrementalScala') && - !configurationsToExclude.contains((conf as Configuration).name) && - !ConfigurationFilters.safelyHasAResolutionAlternative(conf as Configuration) - } -} diff --git a/src/main/groovy/nebula/plugin/dependencyverifier/DependencyResolutionVerifierExtension.groovy b/src/main/groovy/nebula/plugin/dependencyverifier/DependencyResolutionVerifierExtension.groovy index 9db8bd10..ca5e0894 100644 --- a/src/main/groovy/nebula/plugin/dependencyverifier/DependencyResolutionVerifierExtension.groovy +++ b/src/main/groovy/nebula/plugin/dependencyverifier/DependencyResolutionVerifierExtension.groovy @@ -19,5 +19,5 @@ class DependencyResolutionVerifierExtension { boolean shouldFailTheBuild = true Set configurationsToExclude = [] as Set String missingVersionsMessageAddition = '' - Set tasksToExclude = ['clean', 'help'] as Set + Set tasksToExclude = [] as Set } diff --git a/src/main/kotlin/nebula/plugin/dependencylock/DependencyLockPlugin.kt b/src/main/kotlin/nebula/plugin/dependencylock/DependencyLockPlugin.kt index 65966d50..b3588e18 100644 --- a/src/main/kotlin/nebula/plugin/dependencylock/DependencyLockPlugin.kt +++ b/src/main/kotlin/nebula/plugin/dependencylock/DependencyLockPlugin.kt @@ -75,7 +75,7 @@ class DependencyLockPlugin : Plugin { /* MigrateToCoreLocks can be involved with migrating dependencies that were previously unlocked. Verifying resolution based on the base lockfiles causes a `LockOutOfDateException` from the initial DependencyLockingArtifactVisitor state */ - DependencyResolutionVerifier.verifySuccessfulResolution(project) + DependencyResolutionVerifier().verifySuccessfulResolution(project) } val extension = project.extensions.create(EXTENSION_NAME, DependencyLockExtension::class.java) diff --git a/src/main/kotlin/nebula/plugin/dependencyverifier/DependencyResolutionVerifier.kt b/src/main/kotlin/nebula/plugin/dependencyverifier/DependencyResolutionVerifier.kt new file mode 100644 index 00000000..fda72ca8 --- /dev/null +++ b/src/main/kotlin/nebula/plugin/dependencyverifier/DependencyResolutionVerifier.kt @@ -0,0 +1,264 @@ +/** + * + * Copyright 2020 Netflix, Inc. + * + * 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 nebula.plugin.dependencyverifier + +import nebula.plugin.dependencylock.utils.ConfigurationFilters +import nebula.plugin.dependencyverifier.exceptions.DependencyResolutionException +import org.gradle.BuildResult +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.artifacts.Configuration +import org.gradle.api.artifacts.ResolveException +import org.gradle.api.execution.TaskExecutionListener +import org.gradle.api.logging.Logging +import org.gradle.api.tasks.TaskState +import org.gradle.api.tasks.compile.AbstractCompile +import org.gradle.api.tasks.diagnostics.DependencyInsightReportTask +import org.gradle.api.tasks.diagnostics.DependencyReportTask +import org.gradle.internal.exceptions.DefaultMultiCauseException +import org.gradle.internal.locking.LockOutOfDateException +import org.gradle.internal.resolve.ModuleVersionNotFoundException + +const val UNRESOLVED_DEPENDENCIES_FAIL_THE_BUILD: String = "dependencyResolutionVerifier.unresolvedDependenciesFailTheBuild" +const val CONFIGURATIONS_TO_EXCLUDE: String = "dependencyResolutionVerifier.configurationsToExclude" + +class DependencyResolutionVerifier { + + companion object { + var failedDependenciesPerProjectForConfigurations: MutableMap>> = mutableMapOf() + var lockedDepsOutOfDatePerProject: MutableMap> = mutableMapOf() + } + + private val logger by lazy { Logging.getLogger(DependencyResolutionVerifier::class.java) } + private var providedErrorMessageForThisProject = false + + lateinit var project: Project + lateinit var extension: DependencyResolutionVerifierExtension + lateinit var configurationsToExcludeOverride: MutableSet + + fun verifySuccessfulResolution(project: Project) { + this.project = project + this.extension = project.rootProject.extensions.findByType(DependencyResolutionVerifierExtension::class.java)!! + this.configurationsToExcludeOverride = mutableSetOf() + if(project.hasProperty(CONFIGURATIONS_TO_EXCLUDE)) { + configurationsToExcludeOverride.addAll((project.property(CONFIGURATIONS_TO_EXCLUDE) as String).split(",")) + } + failedDependenciesPerProjectForConfigurations[uniqueProjectKey(project)] = mutableMapOf() + lockedDepsOutOfDatePerProject[uniqueProjectKey(project)] = mutableSetOf() + + verifyResolution(project) + } + + private fun verifyResolution(project: Project) { + project.gradle.buildFinished { buildResult -> + val buildFailed: Boolean = buildResult.failure != null + if (buildFailed && !providedErrorMessageForThisProject) { + collectDependencyResolutionErrorsAfterBuildFailure(buildResult) + logOrThrowOnFailedDependencies() + } + } + + project.gradle.taskGraph.whenReady { taskGraph -> + val tasks: List = taskGraph.allTasks.filter { it.project == project } + if (tasks.isEmpty()) { + return@whenReady + } + + taskGraph.addTaskExecutionListener(object : TaskExecutionListener { + override fun beforeExecute(task: Task) { + //DO NOTHING + } + + override fun afterExecute(task: Task, taskState: TaskState) { + if (task.project != project) { + return + } + if (extension.tasksToExclude.contains(task.name)) { + return + } + if (providedErrorMessageForThisProject) { + return + } + if(task !is DependencyReportTask && task !is DependencyInsightReportTask && task !is AbstractCompile) { + return + } + collectDependencyResolutionErrorsAfterExecute(task) + logOrThrowOnFailedDependencies() + } + + }) + } + } + + private fun collectDependencyResolutionErrorsAfterBuildFailure(buildResult: BuildResult) { + val failureCause = buildResult.failure?.cause?.cause + if(failureCause == null || failureCause !is DefaultMultiCauseException) { + return + } + val moduleVersionNotFoundCauses: List = failureCause.causes.filterIsInstance() + if (moduleVersionNotFoundCauses.isEmpty()) { + return + } + val buildResultFailureMessage = failureCause.message + val split = buildResultFailureMessage!!.split(":") + val projectNameFromFailure: String + projectNameFromFailure = if (split.size == 3) { + split[1] + } else { + project.rootProject.name + } + if (project.name == projectNameFromFailure) { + logger.debug("Starting dependency resolution verification after the build has completed: $buildResultFailureMessage") + + val conf: Configuration + try { + val confName: String = buildResultFailureMessage.replace(".", "").split("for ")[1] + conf = project.configurations.first { it.toString() == confName } + logger.debug("Found $conf from $confName") + } catch (e: Exception) { + logger.warn("Error finding configuration associated with build failure from '${buildResultFailureMessage}'", e) + return + } + + val failedDepsByConf = failedDependenciesPerProjectForConfigurations[uniqueProjectKey(project)] + moduleVersionNotFoundCauses.forEach { + require(it is ModuleVersionNotFoundException) + + val dep: String = it.selector.toString() + if (failedDepsByConf!!.containsKey(dep)) { + failedDepsByConf[dep]!!.add(conf) + } else { + failedDepsByConf[dep] = mutableSetOf(conf) + } + } + } + } + + private fun collectDependencyResolutionErrorsAfterExecute(task: Task) { + val failedDepsByConf = failedDependenciesPerProjectForConfigurations[uniqueProjectKey(project)] + val lockedDepsOutOfDate = lockedDepsOutOfDatePerProject[uniqueProjectKey(project)] + val configurationsToExclude = if (configurationsToExcludeOverride.isNotEmpty()) configurationsToExcludeOverride else extension.configurationsToExclude + + task.project.configurations.matching { // returns a live collection + configurationIsResolvedAndMatches(it, configurationsToExclude) + }.all { conf -> + logger.debug("$conf in ${project.name} has state ${conf.state}. Starting dependency resolution verification after task '${task.name}'.") + try { + conf.resolvedConfiguration.resolvedArtifacts + } catch (e: Exception) { + when(e) { + is ResolveException -> { + e.causes.forEach { cause -> + when(cause) { + is ModuleVersionNotFoundException -> { + val dep: String = cause.selector.toString() + if(failedDepsByConf!!.containsKey(dep)) { + failedDepsByConf[dep]!!.add(conf) + } else { + failedDepsByConf[dep] = mutableSetOf(conf) + } + } + is LockOutOfDateException -> { + lockedDepsOutOfDate!!.add(cause.message.toString()) + } + } + } + } + + else -> { + logger.warn("Received an unhandled exception", e.message) + } + } + } + } + } + + private fun logOrThrowOnFailedDependencies() { + val message: MutableList = mutableListOf() + val depsMissingVersions: MutableList = mutableListOf() + + val failedDepsForConfs = failedDependenciesPerProjectForConfigurations[uniqueProjectKey(project)]!! + val lockedDepsOutOfDate = lockedDepsOutOfDatePerProject[uniqueProjectKey(project)]!! + if (failedDepsForConfs.isNotEmpty() || lockedDepsOutOfDate.isNotEmpty()) { + try { + if (failedDepsForConfs.isNotEmpty()) { + message.add("Failed to resolve the following dependencies:") + } + var failureMessageCounter = 0 + failedDepsForConfs.toSortedMap().forEach { (dep, _) -> + message.add(" ${failureMessageCounter + 1}. Failed to resolve '$dep' for project '${project.name}'") + + if (dep.split(':').size < 3) { + depsMissingVersions.add(dep) + } + + failureMessageCounter += 1 + } + + if (lockedDepsOutOfDate.isNotEmpty()) { + message.add("Resolved dependencies were missing from the lock state:") + } + + var locksOutOfDateCounter = 0 + lockedDepsOutOfDate + .sorted() + .forEach { outOfDateMessage-> + message.add(" ${locksOutOfDateCounter + 1}. $outOfDateMessage for project '${project.name}'") + locksOutOfDateCounter += 1 + } + + if (depsMissingVersions.size > 0) { + message.add("The following dependencies are missing a version: ${depsMissingVersions.joinToString()}\n" + + "Please add a version to fix this. If you have been using a BOM, perhaps these dependencies are no longer managed. \n" + + extension.missingVersionsMessageAddition) + } + } catch (e: Exception) { + logger.warn("Error creating message regarding failed dependencies", e) + return + } + + providedErrorMessageForThisProject = true + if (unresolvedDependenciesShouldFailTheBuild()) { + throw DependencyResolutionException(message.joinToString("\n")) + } else { + logger.warn(message.joinToString("\n")) + } + } + } + + private fun configurationIsResolvedAndMatches(conf: Configuration, configurationsToExclude: Set) : Boolean { + return conf.state != Configuration.State.UNRESOLVED && + // the configurations `incrementalScalaAnalysisFor_x_` are resolvable only from a scala context + !conf.name.startsWith("incrementalScala") && + !configurationsToExclude.contains(conf.name) && + !ConfigurationFilters.safelyHasAResolutionAlternative(conf) + } + + private fun unresolvedDependenciesShouldFailTheBuild() :Boolean { + return if (project.hasProperty(UNRESOLVED_DEPENDENCIES_FAIL_THE_BUILD)) { + (project.property(UNRESOLVED_DEPENDENCIES_FAIL_THE_BUILD) as String).toBoolean() + } else { + extension.shouldFailTheBuild + } + } + + private fun uniqueProjectKey(project: Project): String { + return "${project.name}-${if(project == project.rootProject) "rootproject" else "subproject"}" + } +} \ No newline at end of file diff --git a/src/test/groovy/nebula/plugin/dependencylock/DependencyLockPluginWithCoreVerifierSpec.groovy b/src/test/groovy/nebula/plugin/dependencylock/DependencyLockPluginWithCoreVerifierSpec.groovy index f884612a..1987bfd9 100644 --- a/src/test/groovy/nebula/plugin/dependencylock/DependencyLockPluginWithCoreVerifierSpec.groovy +++ b/src/test/groovy/nebula/plugin/dependencylock/DependencyLockPluginWithCoreVerifierSpec.groovy @@ -1,13 +1,13 @@ package nebula.plugin.dependencylock -import nebula.plugin.dependencyverifier.DependencyResolutionVerifier +import nebula.plugin.dependencyverifier.DependencyResolutionVerifierKt import nebula.test.dependencies.DependencyGraphBuilder import nebula.test.dependencies.GradleDependencyGenerator import nebula.test.dependencies.ModuleBuilder import spock.lang.Subject import spock.lang.Unroll -@Subject(DependencyResolutionVerifier) +@Subject(DependencyResolutionVerifierKt) class DependencyLockPluginWithCoreVerifierSpec extends AbstractDependencyLockPluginSpec { private static final String BASELINE_LOCKFILE_CONTENTS = """# This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. @@ -140,7 +140,6 @@ test.nebula:b:1.1.0 results.output.contains('FAILURE') results.output.contains('Resolved dependencies were missing from the lock state') results.output.contains('Resolved \'test.nebula:d:1.0.0\' which is not part of the dependency lock state') - results.output.contains('Resolved \'test.nebula:e:1.0.0\' which is not part of the dependency lock state') } @Unroll @@ -188,12 +187,6 @@ test.nebula:b:1.1.0 results.output.contains("> Failed to resolve the following dependencies") results.output.contains(failedResolutionDependencies('sub1')) - results.output.contains(""" - 1. Failed to resolve 'not.available:a:1.0.0' for project 'sub2' - 2. Failed to resolve 'test.nebula:c' for project 'sub2' - 3. Failed to resolve 'test.nebula:e' for project 'sub2' - 4. Failed to resolve 'transitive.not.available:a:1.0.0' for project 'sub2'""") - where: lockArg << ['write-locks', 'update-locks'] } diff --git a/src/test/groovy/nebula/plugin/dependencyverifier/DependencyResolutionVerifierTest.groovy b/src/test/groovy/nebula/plugin/dependencyverifier/DependencyResolutionVerifierTest.groovy index d9886978..8ea165c1 100644 --- a/src/test/groovy/nebula/plugin/dependencyverifier/DependencyResolutionVerifierTest.groovy +++ b/src/test/groovy/nebula/plugin/dependencyverifier/DependencyResolutionVerifierTest.groovy @@ -25,7 +25,7 @@ import nebula.test.dependencies.ModuleBuilder import spock.lang.Subject import spock.lang.Unroll -@Subject(DependencyResolutionVerifier) +@Subject(DependencyResolutionVerifierKt) class DependencyResolutionVerifierTest extends IntegrationTestKitSpec { def mavenrepo @@ -59,8 +59,8 @@ class DependencyResolutionVerifierTest extends IntegrationTestKitSpec { !results.output.contains('FAILURE') where: - tasks | description - ['dependencies'] | 'explicitly resolve dependencies' + tasks | description + ['dependencies', '--configuration', 'compileClasspath'] | 'explicitly resolve dependencies' } @Unroll @@ -84,10 +84,10 @@ class DependencyResolutionVerifierTest extends IntegrationTestKitSpec { results.output.contains("1. Failed to resolve 'not.available:a:1.0.0' for project") where: - tasks | description - ['build'] | 'resolve dependencies naturally' - ['dependencies'] | 'explicitly resolve dependencies' - ['dependencies', 'build', 'buildEnvironment'] | 'explicitly resolve as part of task chain' + tasks | description + ['build'] | 'resolve dependencies naturally' + ['dependencies', '--configuration', 'compileClasspath'] | 'explicitly resolve dependencies' + ['dependencies', '--configuration', 'compileClasspath', 'build', 'buildEnvironment'] | 'explicitly resolve as part of task chain' } @Unroll @@ -111,10 +111,10 @@ class DependencyResolutionVerifierTest extends IntegrationTestKitSpec { results.output.contains("1. Failed to resolve 'transitive.not.available:a:1.0.0' for project") where: - tasks | description - ['build'] | 'resolve dependencies naturally' - ['dependencies'] | 'explicitly resolve dependencies' - ['dependencies', 'build', 'buildEnvironment'] | 'explicitly resolve as part of task chain' + tasks | description + ['build'] | 'resolve dependencies naturally' + ['dependencies', '--configuration', 'compileClasspath'] | 'explicitly resolve dependencies' + ['dependencies', '--configuration', 'compileClasspath', 'build', 'buildEnvironment'] | 'explicitly resolve as part of task chain' } @Unroll @@ -142,10 +142,10 @@ class DependencyResolutionVerifierTest extends IntegrationTestKitSpec { results.output.contains("If you have been using a BOM") where: - tasks | description - ['build'] | 'resolve dependencies naturally' - ['dependencies'] | 'explicitly resolve dependencies' - ['dependencies', 'build', 'buildEnvironment'] | 'explicitly resolve as part of task chain' + tasks | description + ['build'] | 'resolve dependencies naturally' + ['dependencies', '--configuration', 'compileClasspath'] | 'explicitly resolve dependencies' + ['dependencies', '--configuration', 'compileClasspath', 'build', 'buildEnvironment'] | 'explicitly resolve as part of task chain' } @Unroll @@ -154,7 +154,7 @@ class DependencyResolutionVerifierTest extends IntegrationTestKitSpec { setupSingleProject() buildFile << """ dependencies { - testImplementation 'junit:junit:999.99.9' // version is invalid yet needed for compilation + implementation 'junit:junit:999.99.9' // version is invalid yet needed for compilation } """.stripIndent() writeUnitTest() // valid version of the junit library is not in the dependency declaration @@ -169,10 +169,10 @@ class DependencyResolutionVerifierTest extends IntegrationTestKitSpec { results.output.contains("1. Failed to resolve 'junit:junit:999.99.9' for project") where: - tasks | description - ['build'] | 'resolve dependencies naturally' - ['dependencies'] | 'explicitly resolve dependencies' - ['dependencies', 'build', 'buildEnvironment'] | 'explicitly resolve as part of task chain' + tasks | description + ['build'] | 'resolve dependencies naturally' + ['dependencies', '--configuration', 'compileClasspath'] | 'explicitly resolve dependencies' + ['dependencies', '--configuration', 'compileClasspath', 'build', 'buildEnvironment'] | 'explicitly resolve as part of task chain' } @Unroll @@ -201,18 +201,58 @@ class DependencyResolutionVerifierTest extends IntegrationTestKitSpec { results.output.contains('> Failed to resolve the following dependencies:') results.output.contains("1. Failed to resolve 'not.available:apricot:1.0.0' for project 'sub1'") - if (tasks != ['build']) { - // the `dependencies` task does not normally fail on resolution failures - // the `build` task will fail on resolution failures - // when a task fails, then the project will not continue to a subsequent task - results.output.contains("1. Failed to resolve 'not.available:banana-leaf:2.0.0' for project 'sub2'") - } + where: + tasks | description + ['build'] | 'resolve dependencies naturally' + ['dependenciesForAll', '--configuration', 'compileClasspath'] | 'explicitly resolve dependencies' + ['dependenciesForAll', '--configuration', 'compileClasspath', 'build', 'buildEnvironment'] | 'explicitly resolve as part of task chain' + } + + @Unroll + def 'multiproject: handles worker threads from spotbugs - #description'() { + given: + buildFile << """ + buildscript { + repositories { maven { url "https://plugins.gradle.org/m2/" } } + dependencies { + classpath "gradle.plugin.com.github.spotbugs.snom:spotbugs-gradle-plugin:4.4.4" + } + } + """.stripIndent() + setupMultiProject() + buildFile << """ + subprojects { + apply plugin: "com.github.spotbugs" + repositories { + mavenCentral() + } + } + """.stripIndent() + + new File(projectDir, 'sub1/build.gradle') << """ \ + dependencies { + testImplementation 'junit:junit:4.12' + } + """.stripIndent() + + new File(projectDir, 'sub2/build.gradle') << """ \ + dependencies { + testImplementation 'junit:junit:4.12' + } + """.stripIndent() + + when: + def results = runTasks(*tasks, '--warning-mode', 'all') + + then: + !results.output.contains('FAILURE') + !results.output.contains('was resolved without accessing the project in a safe manner') where: - tasks | description - ['build'] | 'resolve dependencies naturally' - ['dependenciesForAll'] | 'explicitly resolve dependencies' - ['dependenciesForAll', 'build', 'buildEnvironment'] | 'explicitly resolve as part of task chain' + tasks | description + ['spotbugsMain'] | 'calling spotbugsMain' + ['build'] | 'resolve dependencies naturally' + ['dependenciesForAll', '--configuration', 'compileClasspath'] | 'explicitly resolve dependencies' } @Unroll @@ -243,10 +283,10 @@ class DependencyResolutionVerifierTest extends IntegrationTestKitSpec { results.output.contains("1. Failed to resolve 'not.available:banana-leaf:2.0.0' for project 'sub2'") where: - tasks | description - ['build'] | 'resolve dependencies naturally' - ['dependenciesForAll'] | 'explicitly resolve dependencies' - ['dependenciesForAll', 'build', 'buildEnvironment'] | 'explicitly resolve as part of task chain' + tasks | description + ['build'] | 'resolve dependencies naturally' + ['dependenciesForAll', '--configuration', 'compileClasspath'] | 'explicitly resolve dependencies' + ['dependenciesForAll', '--configuration', 'compileClasspath', 'build', 'buildEnvironment'] | 'explicitly resolve as part of task chain' } @Unroll @@ -263,9 +303,9 @@ class DependencyResolutionVerifierTest extends IntegrationTestKitSpec { !results.output.contains('> Failed to resolve the following dependencies:') where: - tasks | description - ['build'] | 'resolve dependencies naturally' - ['dependencies', 'build', 'buildEnvironment'] | 'explicitly resolve as part of task chain' + tasks | description + ['build'] | 'resolve dependencies naturally' + ['dependencies', '--configuration', 'compileClasspath', 'build', 'buildEnvironment'] | 'explicitly resolve as part of task chain' } @Unroll @@ -292,7 +332,7 @@ class DependencyResolutionVerifierTest extends IntegrationTestKitSpec { """.stripIndent() when: - def tasks = ['dependencies'] + def tasks = ['dependencies', '--configuration', 'compileClasspath'] if (setupStyle == 'command line') { tasks += '-PdependencyResolutionVerifier.configurationsToExclude=specialConfig,otherSpecialConfig' } @@ -359,7 +399,7 @@ class DependencyResolutionVerifierTest extends IntegrationTestKitSpec { when: def results - def tasks = ['dependencies'] + def tasks = ['dependencies', '--configuration', 'compileClasspath'] if (expecting == 'error') { results = runTasksAndFail(*tasks) @@ -389,7 +429,6 @@ class DependencyResolutionVerifierTest extends IntegrationTestKitSpec { given: setupSingleProject() setupTaskThatRequiresResolvedConfiguration(buildFile) - forwardOutput = true buildFile << """ dependencies { @@ -463,7 +502,7 @@ class DependencyResolutionVerifierTest extends IntegrationTestKitSpec { setupTaskThatRequiresResolvedConfiguration(sub1BuildFile) setupTaskThatRequiresResolvedConfiguration(sub2BuildFile) - sub1BuildFile << """ + sub1BuildFile << """ dependencies { implementation '$dependency' } @@ -496,7 +535,6 @@ class DependencyResolutionVerifierTest extends IntegrationTestKitSpec { def 'uses extension for #description'() { given: setupSingleProject() - forwardOutput = true def configurationName = description == 'configurationsToExclude' ? 'myConfig' : 'implementation' @@ -506,7 +544,7 @@ class DependencyResolutionVerifierTest extends IntegrationTestKitSpec { dependencies { $configurationName 'not.available:a' } - import nebula.plugin.dependencyverifier.DependencyResolutionVerifierExtension + import nebula.plugin.dependencyverifier.DependencyResolutionVerifierExtension plugins.withId('nebula.dependency-lock') { def extension = extensions.getByType(DependencyResolutionVerifierExtension.class) def list = new ArrayList<>() @@ -538,6 +576,243 @@ class DependencyResolutionVerifierTest extends IntegrationTestKitSpec { "list.addAll('dependencies')\n\textension.tasksToExclude = list" | 'tasksToExclude' | false | false } + def 'handles root and subproject of the same name'() { + given: + setupMultiProject() + + def sub1BuildFile = new File(projectDir, 'sub1/build.gradle') + sub1BuildFile << """ + dependencies { + implementation 'not.available:a:1.0.0' // dependency is not found + } + """.stripIndent() + + settingsFile.createNewFile() + settingsFile.text = """ + rootProject.name='sub1' + include "sub1" + include "sub2" + """.stripIndent() + + when: + def results = runTasksAndFail('build') + + then: + results.output.contains('FAILURE') + results.output.contains('Execution failed for task') + results.output.contains('> Failed to resolve the following dependencies:') + results.output.findAll('> Failed to resolve the following dependencies:').size() == 1 + results.output.contains("1. Failed to resolve 'not.available:a:1.0.0' for project 'sub1'") + } + + def 'handles build failure from task configuration issue'() { + given: + setupSingleProject() + buildFile << """ + dependencies { + implementation 'not.available:a:1.0.0' // dependency is not found + } + task goodbye { + println "Goodbye!" + } + build.finalizedBy project.tasks.named('goodbye') onlyIf { + project.configurations.compileClasspath.resolvedConfiguration.resolvedArtifacts.collect {it.name}.contains('bananan') + } + """.stripIndent() + + when: + def results = runTasksAndFail('build') + + then: + results.output.contains('FAILURE') + results.output.contains('Execution failed for task') + results.output.contains('> Failed to resolve the following dependencies:') + results.output.findAll('> Failed to resolve the following dependencies:').size() == 1 + results.output.contains("1. Failed to resolve 'not.available:a:1.0.0' for project") + } + + def 'handles task that requires resolved configuration with no issues'() { + given: + setupSingleProject() + + buildFile << """ + ${taskThatRequiresConfigurationDependencies()} + """.stripIndent() + + when: + def results = runTasks('build') + + then: + assert !results.output.contains('FAILURE') + } + + @Unroll + def 'handles task that requires resolved configuration with an issue due to #failureType'() { + given: + setupSingleProject() + + buildFile << """ + dependencies { + implementation '$dependency' + } + ${taskThatRequiresConfigurationDependencies()} + """.stripIndent() + + when: + def results = runTasksAndFail('build') + + then: + assert results.output.contains('FAILURE') + assert results.output.contains('Failed to resolve the following dependencies:') + assert results.output.contains("1. Failed to resolve '${actualMissingDep ?: dependency}' for project") + + where: + failureType | dependency | actualMissingDep + 'missing version' | 'not.available:a' | null + 'direct dep not found' | 'not.available:a:1.0.0' | null + 'transitive dep not found' | 'has.missing.transitive:a:1.0.0' | 'transitive.not.available:a:1.0.0' + } + + @Unroll + def 'handles task that requires resolved configuration with an issue due to #failureType - multiproject'() { + given: + setupMultiProject() + + def sub1BuildFile = new File(projectDir, 'sub1/build.gradle') + def sub2BuildFile = new File(projectDir, 'sub2/build.gradle') + + sub1BuildFile << """ \ + dependencies { + implementation '$dependency' + } + ${taskThatRequiresConfigurationDependencies()} + """.stripIndent() + + def sub2Dependency = dependency.replace(':a', ':b') + sub2BuildFile << """ + dependencies { + implementation '$sub2Dependency' + } + ${taskThatRequiresConfigurationDependencies()} + """.stripIndent() + + when: + def results = runTasksAndFail('build') + + then: + assert results.output.contains('FAILURE') + assert results.output.contains('Failed to resolve the following dependencies:') + assert results.output.contains("1. Failed to resolve '${actualMissingDep ?: dependency}' for project 'sub1'") + assert !results.output.contains("for project 'sub2'") + + where: + failureType | dependency | actualMissingDep + 'missing version' | 'not.available:a' | null + 'direct dep not found' | 'not.available:a:1.0.0' | null + 'transitive dep not found' | 'has.missing.transitive:a:1.0.0' | 'transitive.not.available:a:1.0.0' + } + + @Unroll + def 'handles task that requires resolved configuration with an issue due to #failureType - multiproject and parallel'() { + given: + setupMultiProject() + + def sub1BuildFile = new File(projectDir, 'sub1/build.gradle') + def sub2BuildFile = new File(projectDir, 'sub2/build.gradle') + + sub1BuildFile << """ + dependencies { + implementation '$dependency' + } + ${taskThatRequiresConfigurationDependencies()} + """.stripIndent() + + def sub2Dependency = dependency.replace(':a', ':b') + sub2BuildFile << """ + dependencies { + implementation '$sub2Dependency' + } + ${taskThatRequiresConfigurationDependencies()} + """.stripIndent() + + when: + def results = runTasksAndFail('build') + + then: + assert results.output.contains('FAILURE') + assert results.output.contains('Failed to resolve the following dependencies:') + assert results.output.contains("1. Failed to resolve '${actualMissingDep ?: dependency}' for project 'sub1'") + assert !results.output.contains("for project 'sub2'") + + where: + failureType | dependency | actualMissingDep + 'missing version' | 'not.available:a' | null + 'direct dep not found' | 'not.available:a:1.0.0' | null + 'transitive dep not found' | 'has.missing.transitive:a:1.0.0' | 'transitive.not.available:a:1.0.0' + } + + @Unroll + def 'with Gradle version #gradleVersionToTest - expecting #expecting - using task with configuration dependencies'() { + given: + gradleVersion = gradleVersionToTest + setupSingleProject() + + buildFile << taskThatRequiresConfigurationDependencies() + + if (expecting == 'error') { + buildFile << """ + dependencies { + implementation 'not.available:a:1.0.0' // dependency is not found + } + """.stripIndent() + } + + when: + def results + def tasks = ['dependencies', '--configuration', 'compileClasspath'] + + if (expecting == 'error') { + results = runTasksAndFail(*tasks) + } else { + results = runTasks(*tasks) + } + + then: + if (expecting == 'error') { + assert results.output.contains('Could not determine the dependencies of task') + assert results.output.contains("1. Failed to resolve 'not.available:a:1.0.0' for project") + } else { + assert results.output.contains('Task :dependencies') + } + + where: + gradleVersionToTest | expecting + '6.0.1' | 'error' + '6.0.1' | 'no error' + '5.6.4' | 'error' + '5.6.4' | 'no error' + '5.1' | 'error' + '5.1' | 'no error' + '4.10.3' | 'error' + '4.10.3' | 'no error' + '4.9' | 'error' + '4.9' | 'no error' + } + + private static String taskThatRequiresConfigurationDependencies() { + return """ + task taskWithConfigurationDependencies { + inputs.files configurations.compileClasspath.incoming.artifacts + doLast { configurations.compileClasspath.each { } } + } + if(project.tasks.findByName('dependenciesForAll') != null) { + project.tasks.getByName('dependenciesForAll').dependsOn project.tasks.named('taskWithConfigurationDependencies') + } + project.tasks.getByName('dependencies').dependsOn project.tasks.named('taskWithConfigurationDependencies') + project.tasks.getByName('build').dependsOn project.tasks.named('taskWithConfigurationDependencies') + """.stripIndent() + } + private static void setupTaskThatRequiresResolvedConfiguration(File specificBuildFile) { assert specificBuildFile != null @@ -581,8 +856,6 @@ class DependencyResolutionVerifierTest extends IntegrationTestKitSpec { } private void setupMultiProject() { - buildFile.delete() - buildFile.createNewFile() buildFile << """ plugins { id 'java'