├── .github └── workflows │ └── gradle.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── RELEASE_NOTES.md ├── _config.yml ├── build.gradle ├── demo ├── bugKotlinDslCantReferToExtensionBlock │ ├── README.md │ ├── build.gradle.kts │ ├── runbuild.sh │ └── settings.gradle ├── shouldDetectDupes │ ├── README.md │ ├── build.gradle │ ├── runbuild.sh │ └── settings.gradle └── shouldPass │ ├── README.md │ ├── build.gradle │ ├── runbuild.sh │ └── settings.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src ├── main ├── groovy │ └── classpathHell │ │ ├── ClasspathHellPlugin.groovy │ │ ├── ClasspathHellPluginExtension.groovy │ │ └── ClasspathHellTask.groovy └── resources │ └── META-INF │ └── gradle-plugins │ └── com.portingle.classpathHell.properties └── test └── groovy └── classpathHell └── ClasspathHellPluginTests.groovy /.github/workflows/gradle.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle 3 | 4 | name: Java CI with Gradle 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up JDK 15 20 | uses: actions/setup-java@v2 21 | with: 22 | java-version: '15' 23 | distribution: 'adopt' 24 | cache: gradle 25 | - name: Grant execute permission for gradlew 26 | run: chmod +x gradlew 27 | - name: Build with Gradle 28 | run: ./gradlew build 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out/ 2 | build/* 3 | .idea/* 4 | *.iml 5 | .gradle/* 6 | **/build/* 7 | **/.idea/* 8 | **/*.iml 9 | **/.gradle/* 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: groovy 2 | 3 | before_cache: 4 | - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock 5 | cache: 6 | directories: 7 | - $HOME/.gradle/caches/ 8 | - $HOME/.gradle/wrapper/ 9 | 10 | jdk: 11 | - oraclejdk15 12 | 13 | dist: precise 14 | 15 | install: true 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 John Lonergan 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # classpathHell - Classpath Mayhem Detector 2 | 3 | __Think your build is stable and repeatable? Think again.__ 4 | 5 | It's far too easy to end up with multiple copies of a class or resource on your classpath leading to 6 | difficult to trace runtime errors that, due to classpath ordering instability, might not show up until 7 | late in your release cycle, or possibly even production. 8 | 9 | Don't let that happen - just use this detector. 10 | 11 | classpathHell is a gradle plugin that breaks the build if there are classpath collisions. 12 | 13 | An excellent example is that Dropwizard and Codahale metrics have the same fully qualified class names, but with different interfaces and different implementations. 14 | 15 | Other problems include where we have _-all_ jars included in addition to discrete jars (see the Hamcrest examples below). 16 | In such cases the dependency resolution in Gradle (or maven) won't help. 17 | 18 | What you need to to spot those cases where there are dupes and then eliminate the dupes or if you deem it safe then suppress the specific violation. 19 | This plugin provides that functionality. 20 | 21 | Note: 22 | I discovered after creating classpathHell that there are two similar plugins out there: 23 | - https://github.com/nebula-plugins/gradle-lint-plugin/wiki/Duplicate-Classes-Rule 24 | - https://plugins.gradle.org/plugin/net.idlestate.gradle-duplicate-classes-check 25 | 26 | I haven't looked at these yet and it is conceivable that these will be a better fit for you, but classpathHell seems to be more configurable, allowing suppressions etc. 27 | 28 | ## Getting started 29 | 30 | ### Short demo 31 | 32 | In this example we will deliberately include two conflicting jars from Hamcrest; the core jar and the "-all" jar. 33 | Given the 'all' jar also contains all the core classes this combination will introduce many duplicate resources to the classpath. 34 | The example below if run will demonstrate the reporting. 35 | 36 | ```groovy 37 | 38 | repositories { 39 | mavenCentral() 40 | } 41 | 42 | buildscript { 43 | 44 | repositories { 45 | mavenCentral() 46 | } 47 | 48 | // OLD STYLE DEPENDENCY 49 | // dependencies { 50 | // // check maven central for the latest release 51 | // classpath "com.portingle:classpath-hell:1.9" 52 | //} 53 | } 54 | 55 | 56 | plugins { 57 | // NEW STYLE PLUGIN 58 | id('com.portingle.classpath-hell').version("1.9") 59 | } 60 | 61 | apply plugin: 'java' 62 | 63 | // OLD STYLE DEPENDENCY 64 | // apply plugin: 'com.portingle.classpathHell' 65 | 66 | // introduce some deliberate BAD deps with dupes 67 | 68 | dependencies { 69 | implementation group: 'org.hamcrest', name: 'hamcrest-all', version: '1.3' 70 | implementation group: 'org.hamcrest', name: 'hamcrest-core', version: '1.3' 71 | } 72 | 73 | // link this plugin into the build cycle 74 | build.dependsOn(['checkClasspath']) 75 | ``` 76 | 77 | ### Restricting the gradle configuration scan 78 | 79 | It is *mandatory* to configure "configurationsToScan" to restrict the scanning to a defined set of configurations. 80 | 81 | Without this setting then all configurations are scanned and resolved, but later versions of gradle object to this as certain configurations cannot be enumerated. 82 | Gradle configurations have a property 'canBeResolved' that identifies those that we can scan safely. 83 | 84 | At the time of writing those configurations can NOT be resolved include: 85 | 86 | 87 | So make sure to fill this setting in as needed. 88 | 89 | ```groovy 90 | classpathHell { 91 | configurationsToScan = [ configurations.runtimeClasspath ] 92 | } 93 | 94 | ``` 95 | 96 | ### Suppressing benign duplication 97 | 98 | By default the plugin reports all dupes that it finds, however, the plugin allows one to suppress dupes 99 | where the duplicate resource has exacly the same bytes in each case. 100 | 101 | ```groovy 102 | classpathHell { 103 | suppressExactDupes = true 104 | } 105 | ``` 106 | 107 | ### Resource exclusion 108 | 109 | By default the plugin reports all dupes that it finds, however, the plugin allows one specify a list of resources to exclude from the check. 110 | This is done by configuring the property `resourceExclusions` with a list of regular expressions matching the resource paths to suppress. 111 | 112 | ```groovy 113 | classpathHell { 114 | resourceExclusions = [ "abc.*", "de.*f" ] // list of regexes for resources to exclude 115 | } 116 | ``` 117 | 118 | NOTE: As a convenience the plugin also provides a constant `CommonResourceExclusions()` that can be used to suppress 119 | a set of common dupes that aren't very interesting, for example _^about.html\$_. 120 | 121 | ```groovy 122 | classpathHell { 123 | // resourceExclusions is a list of regex strings that exclude any matching resources from the report 124 | resourceExclusions = CommonResourceExclusions() 125 | } 126 | ``` 127 | 128 | However, if you wish to have more control over the exclusions then take a look at the next section. 129 | 130 | ### Further resource exclusion patterns 131 | 132 | We can configure the plugin to exclude further resources from the report. 133 | 134 | ```groovy 135 | 136 | // some demo configuration 137 | classpathHell { 138 | 139 | // use the convenience value .. 140 | resourceExclusions = CommonResourceExclusions() 141 | 142 | /* Alternatively, we will exclude all classes and a particular directory. 143 | */ 144 | resourceExclusions = [ 145 | // these are pattern matches on the resource path 146 | "somePath/", 147 | ".*class" 148 | ] 149 | 150 | /* Since `resourceExclusions` is a List we can append to it. 151 | */ 152 | resourceExclusions.addAll([ 153 | ".*/", 154 | "anotherPath/.*" 155 | ]) 156 | 157 | /* or use other List functions to remove entries */ 158 | resourceExclusions.remove("somePath") 159 | } 160 | 161 | ``` 162 | 163 | ### Excluding artifacts from the report 164 | 165 | As well as suppressing reports about certain resources being duplicated we can suppress reports relating to entire artifacts; 166 | this is achieved be configuring `artifactExclusions`. 167 | 168 | Of course, ideally you should resolve the conflicting dependencies however sometimes you need a way out and entirely excluding 169 | an artifact from the scan may be necessary. 170 | 171 | ```groovy 172 | classpathHell { 173 | artifactExclusions = [ 174 | // this is a pattern match on the file system path of the build once the dependency has been downloaded locally 175 | ".*hamcrest-core.*" 176 | ] 177 | } 178 | ``` 179 | 180 | 181 | ### Extra info logging 182 | 183 | To get more detailed info level logging there are three options 184 | - set the "trace" property in the gradle config to true. 185 | 186 | ```groovy 187 | classpathHell { 188 | // some additional logging; note one must also run with "--info" if you want to see this as the logging comes out with "INFO" level 189 | trace= true 190 | } 191 | ``` 192 | FYI you must also use "--info" on gradle or you will see nothing. 193 | Note: we don't use debug level for this because that turns on far too much tracing. 194 | 195 | - set by gradle property 196 | 197 | ```bash 198 | ./gradlew -PclasspathHell.trace=true --info build 199 | ``` 200 | FYI you must also use "--info" on gradle or you will see nothing. 201 | 202 | - turn on debug 203 | 204 | ```bash 205 | ./gradlew --debug build 206 | ``` 207 | 208 | ## Gradle task 209 | 210 | To run the check use: 211 | 212 | ``` 213 | ./gradlew checkClasspath 214 | ``` 215 | 216 | But don't forget to wire this plugin into your build so it runs automatically. 217 | 218 | ```groovy 219 | // link this plugin into the build cycle 220 | build.dependsOn(['checkClasspath']) 221 | ``` 222 | 223 | # Troubleshooting 224 | 225 | ## Error "Resolving configuration 'apiElements' directly is not allowed" 226 | 227 | You are probably running Gradle 4.x with an old version of classpathHell - upgrade to 1.2 or later. 228 | -------------------------------------------------------------------------------- /RELEASE_NOTES.md: -------------------------------------------------------------------------------- 1 | # RELEASE NOTES 2 | 3 | - 1.10 4 | 5 | - Uses files from configured configurations as inputs in order to skip execution if nothing changed. 6 | 7 | 8 | - 1.9 9 | 10 | - Changed the log level of _logger.warn("classpathHell: trace=" + doTrace)_ to info so it doesn't clutter the console. 11 | 12 | 13 | - 1.8 14 | 15 | - Enabled use of the newer gradle "plugin id" syntax - see the README 16 | - Upgrade to gradle 8 17 | 18 | 19 | - 1.7.0 20 | 21 | - Upgrade to gradle 7 22 | - Better error reporting for misconfiguration of this plugin 23 | - Tested with Java 18 and Gradle 7 24 | - Fixed ./gradlew publishToMavenLocal 25 | 26 | 27 | - 1.6.0 28 | 29 | - Upgrade to gradle 6 30 | 31 | - 1.5 32 | 33 | - like 1.4 but using a different hashed 34 | 35 | - 1.4 36 | 37 | - allow convenient suppression of 'benign' exact matches where there are dupes but they have the same impl 38 | 39 | ```groovy 40 | classpathHell { 41 | suppressExactDupes = true 42 | } 43 | ``` 44 | 45 | 46 | - 1.3 47 | 48 | - added configurationsToScan 49 | - suppressed a lot of debug logging using a trace flag 50 | 51 | - 1.2 52 | 53 | - add support for Gradle 4 54 | 55 | - 1.1 56 | 57 | - 1.0 58 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // RELEASNG ... 2 | // DO IT IN WINDOWS 3 | // UPDATE build.gradle: version '1.9' 4 | // SETUP: plug gnupg contents into C:/Users/johnl/AppData/Roaming/gnupg 5 | // SETUP: place gradle.properties in c:\users\johnl\gradlerelease\ 6 | // SETUP: gradle.properties refers to the gnupg dir so adjust accordingly 7 | // RELEASE: .\gradlew -g c:\users\johnl\gradlerelease publishPlugins [--validate-only] 8 | // VERIFY: https://plugins.gradle.org/plugin/com.portingle.classpath-hell 9 | 10 | 11 | // TESTING LOCALLY - to install to the local repo for testing 12 | // .\gradlew publishToMavenLocal 13 | 14 | 15 | 16 | plugins { 17 | id 'com.gradle.plugin-publish' version '1.2.0' 18 | id 'groovy' 19 | id 'signing' 20 | 21 | } 22 | 23 | group 'com.portingle' 24 | archivesBaseName = "classpath-hell" 25 | version '1.10' 26 | 27 | repositories { 28 | mavenCentral() 29 | mavenLocal() 30 | } 31 | 32 | dependencies { 33 | implementation gradleApi() 34 | 35 | testImplementation(gradleTestKit()) 36 | 37 | testImplementation group: 'junit', name: 'junit', version: '4.13.1' 38 | } 39 | 40 | 41 | java { 42 | withJavadocJar() 43 | withSourcesJar() 44 | } 45 | 46 | 47 | artifacts { 48 | archives jar, javadocJar, sourcesJar 49 | } 50 | 51 | test { 52 | logging.captureStandardOutput LogLevel.WARN 53 | 54 | 55 | testLogging { 56 | // set options for log level LIFECYCLE 57 | events "failed" 58 | exceptionFormat "full" 59 | showExceptions = true 60 | showCauses = true 61 | showStandardStreams = true 62 | 63 | // remove standard output/error logging from --info builds 64 | // by assigning only 'failed' and 'skipped' events 65 | info.events = ["failed", "skipped"] 66 | } 67 | } 68 | // see http://jdpgrailsdev.github.io/blog/2016/03/29/gradle_test_kit.html 69 | task createPluginClasspath { 70 | //def outputDir = file("${buildDir}/resources/test") 71 | def buildRes = file("${buildDir}/resources/test/plugin-classpath.txt") 72 | def outRes = file("${buildDir.getParent()}/out/test/resources/plugin-classpath.txt") 73 | // for intellij which uses ./out rather than ./build 74 | 75 | inputs.files sourceSets.test.runtimeClasspath 76 | outputs.files(buildRes, outRes) 77 | 78 | doLast { 79 | buildRes.getParentFile().mkdirs() 80 | outRes.getParentFile().mkdirs() 81 | file(buildRes).text = sourceSets.test.runtimeClasspath.join('\n',) 82 | file(outRes).text = sourceSets.test.runtimeClasspath.join('\n',) 83 | } 84 | } 85 | 86 | task runDemoPass(type: Exec) { 87 | dependsOn(["publishToMavenLocal"]) 88 | 89 | workingDir 'demo/shouldPass' 90 | commandLine 'cmd', '/c', '..\\..\\gradlew', 'clean', 'build', '-i' 91 | doFirst { 92 | println("EXEC PASSING DEMO") 93 | } 94 | doLast { 95 | println("\nEXEC END") 96 | } 97 | } 98 | task runDemoFail(type: Exec) { 99 | dependsOn(["publishToMavenLocal"]) 100 | 101 | workingDir 'demo/shouldDetectDupes' 102 | commandLine 'cmd', '/c', '..\\..\\gradlew', 'clean', 'build', '-i' 103 | 104 | ignoreExitValue true 105 | 106 | doFirst { 107 | println("EXEC FAILING DEMO") 108 | } 109 | doLast { 110 | if(executionResult.get().exitValue == 0) { 111 | System.err.println("\nEXEC END - DID NOT GET THE EXPECTED FAILURE!!!") 112 | throw new GradleException("demo at '" + workingDir + "' should have failed but did not fail !!!!!!!") 113 | } 114 | println("\nEXEC END - WITH EXPECTED FAILURE OF SCAN") 115 | } 116 | } 117 | 118 | test.dependsOn(['createPluginClasspath']) 119 | build.finalizedBy(['runDemoPass','runDemoFail']) 120 | 121 | 122 | publishing { 123 | publications { 124 | // Ues pluginMaven - https://github.com/gradle/gradle/issues/10384 125 | // To address... 126 | // Multiple publications with coordinates 'com.portingle:classpath-hell:1.8' are published to repository 'mavenLocal'. The publications will overwrite each other! 127 | classpathPlugin(MavenPublication) { 128 | from components.java 129 | 130 | pom { 131 | name = 'classpath-hell' 132 | packaging = 'jar' 133 | 134 | // optionally artifactId can be defined here 135 | description = 'ClasspathHell Gradle Plugin detects duplicate resources on the classpath' 136 | url = 'https://github.com/portingle/classpathHell' 137 | 138 | scm { 139 | connection = 'scm:git@github.com:portingle/classpathHell.git' 140 | developerConnection = 'scm:git@github.com:portingle/classpathHell.git' 141 | url = 'scm:git@github.com:portingle/classpathHell.git' 142 | } 143 | 144 | licenses { 145 | license { 146 | name = 'The MIT Licence (MIT)' 147 | url = 'https://opensource.org/licenses/MIT' 148 | } 149 | } 150 | 151 | developers { 152 | developer { 153 | id = 'Johnlon' 154 | name = 'John Lonergan' 155 | } 156 | } 157 | } 158 | 159 | } 160 | } 161 | } 162 | 163 | sourceCompatibility = JavaVersion.VERSION_15 164 | targetCompatibility = JavaVersion.VERSION_15 165 | 166 | 167 | gradlePlugin { 168 | website = 'https://github.com/portingle/classpathHell' 169 | vcsUrl = 'https://github.com/portingle/classpathHell.git' 170 | 171 | // Define the plugin 172 | plugins { 173 | classpathhellPlugin { 174 | id = 'com.portingle.classpath-hell' 175 | implementationClass = 'classpathHell.ClasspathHellPlugin' 176 | displayName = 'ClasspathHell Gradle Plugin detects duplicate resources on the classpath' 177 | description = 'ClasspathHell Gradle Plugin detects duplicate resources on the classpath' 178 | tags.addAll(['testing', 'classpath']) 179 | 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /demo/bugKotlinDslCantReferToExtensionBlock/README.md: -------------------------------------------------------------------------------- 1 | 1. First do a `.\gradlew publishToMavenLocal` of the main project to put the plugin jar into the local maven repo. 2 | 3 | 2. Then run `..\..\gradlew clean build -i` here. 4 | 5 | The build should fail due to duplicate licence files and manifests only, as the build.gradle suppresses checks on classes and directories. -------------------------------------------------------------------------------- /demo/bugKotlinDslCantReferToExtensionBlock/build.gradle.kts: -------------------------------------------------------------------------------- 1 | // ADDITIONALLY THIS DEMO USES THE OLD STYLE GRADLE DEP apply plugin 2 | 3 | // expect failure from this build script because hamcrest-all contains hamcrest-core 4 | // so there are lots of dupe"d resources 5 | 6 | repositories { 7 | mavenLocal() 8 | mavenCentral() 9 | } 10 | 11 | buildscript { 12 | 13 | repositories { 14 | mavenLocal() 15 | mavenCentral() 16 | 17 | // include plugin repository where classpath(hell lives from 1.8 onwards) 18 | maven { 19 | url = uri("https://plugins.gradle.org/m2") 20 | } 21 | } 22 | 23 | dependencies { 24 | classpath("com.portingle:classpath-hell:1.9") 25 | } 26 | } 27 | 28 | apply(plugin = "com.portingle.classpath-hell") 29 | 30 | plugins { 31 | `java-library` 32 | } 33 | 34 | // works if I comment this out.... 35 | classpathHell { 36 | 37 | // Demonstrate replacing the default set of exclusions 38 | // leaving only things like license files and manifests in violation 39 | resourceExclusions = listOf( 40 | ".*class", 41 | ".*/", 42 | ) 43 | } 44 | 45 | dependencies { 46 | implementation("org.hamcrest:hamcrest-all:1.3") 47 | implementation("org.hamcrest:hamcrest-core:1.3") 48 | } 49 | 50 | tasks.named("build") { 51 | dependsOn(":checkClasspath") 52 | } 53 | -------------------------------------------------------------------------------- /demo/bugKotlinDslCantReferToExtensionBlock/runbuild.sh: -------------------------------------------------------------------------------- 1 | ../../gradlew build 2 | -------------------------------------------------------------------------------- /demo/bugKotlinDslCantReferToExtensionBlock/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'classpathHell-sample' 2 | 3 | -------------------------------------------------------------------------------- /demo/shouldDetectDupes/README.md: -------------------------------------------------------------------------------- 1 | 1. First do a `.\gradlew publishToMavenLocal` of the main project to put the plugin jar into the local maven repo. 2 | 3 | 2. Then run `..\..\gradlew clean build -i` here. 4 | 5 | The build should fail due to duplicate licence files and manifests only, as the build.gradle suppresses checks on classes and directories. -------------------------------------------------------------------------------- /demo/shouldDetectDupes/build.gradle: -------------------------------------------------------------------------------- 1 | // ADDITIONALLY THIS DEMO USES THE OLD STYLE GRADLE DEP apply plugin 2 | 3 | // expect failure from this build script because hamcrest-all contains hamcrest-core 4 | // so there are lots of dupe'd resources 5 | 6 | repositories { 7 | mavenLocal() 8 | mavenCentral() 9 | } 10 | 11 | buildscript { 12 | 13 | repositories { 14 | mavenLocal() 15 | mavenCentral() 16 | 17 | // include plugin repository where classpath hell lives from 1.8 onwards 18 | maven { 19 | url = "https://plugins.gradle.org/m2" 20 | } 21 | } 22 | 23 | dependencies { 24 | classpath "com.portingle:classpath-hell:1.9" 25 | } 26 | } 27 | 28 | apply plugin: 'com.portingle.classpathHell' 29 | apply plugin: 'java' 30 | 31 | classpathHell { 32 | 33 | // Demonstrate replacing the default set of exclusions 34 | // leaving only things like license files and manifests in violation 35 | resourceExclusions = [ 36 | ".*class", 37 | ".*/", 38 | ] 39 | } 40 | 41 | dependencies { 42 | implementation group: 'org.hamcrest', name: 'hamcrest-all', version: '1.3' 43 | implementation group: 'org.hamcrest', name: 'hamcrest-core', version: '1.3' 44 | } 45 | 46 | build.dependsOn(['checkClasspath']) 47 | -------------------------------------------------------------------------------- /demo/shouldDetectDupes/runbuild.sh: -------------------------------------------------------------------------------- 1 | ../../gradlew build 2 | -------------------------------------------------------------------------------- /demo/shouldDetectDupes/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'classpathHell-sample' 2 | 3 | -------------------------------------------------------------------------------- /demo/shouldPass/README.md: -------------------------------------------------------------------------------- 1 | 1. First do a `.\gradlew publishToMavenLocal` of the main project to put the plugin jar into the local maven repo. 2 | 3 | 2. Then run `..\..\gradlew clean build -i` here. 4 | 5 | The build should pass. -------------------------------------------------------------------------------- /demo/shouldPass/build.gradle: -------------------------------------------------------------------------------- 1 | // ADDITIONALLY THIS DEMO USES THE NEW STYLE GRADLE DEP plugin id 2 | 3 | buildscript { 4 | // OLD STYLE DEP 5 | // dependencies { 6 | // classpath 'com.portingle:classpath-hell:1.9' 7 | // } 8 | 9 | repositories { 10 | mavenLocal() 11 | mavenCentral() 12 | } 13 | } 14 | 15 | plugins { 16 | // NEW STYLE DEP 17 | id('com.portingle.classpath-hell').version("1.9") 18 | } 19 | 20 | repositories { 21 | mavenCentral() 22 | } 23 | 24 | apply plugin: 'java' 25 | 26 | // OLD STYLE DEP 27 | //apply plugin: 'com.portingle.classpathHell' 28 | 29 | 30 | dependencies { 31 | implementation group: 'org.hamcrest', name: 'hamcrest-all', version: '1.3' 32 | implementation group: 'org.hamcrest', name: 'hamcrest-core', version: '1.3' 33 | } 34 | 35 | classpathHell { 36 | // some additional logging; note one must also run with "--info" or "--debug" if you want to see this detail. 37 | // specify the property as shown below or set the gradle property -PclasspathHell.trace=true 38 | trace = true 39 | 40 | // only scan one configuration as this makes the test's verification easier 41 | configurationsToScan = [configurations.runtimeClasspath] 42 | 43 | // suppress a genuine dupe - one that is not an exact dupe 44 | resourceExclusions = ["META-INF/MANIFEST.MF"] 45 | 46 | // configure automatic resolution of "benign" dupes 47 | suppressExactDupes = true 48 | } 49 | 50 | build.dependsOn("checkClasspath") 51 | -------------------------------------------------------------------------------- /demo/shouldPass/runbuild.sh: -------------------------------------------------------------------------------- 1 | ../../gradlew build 2 | -------------------------------------------------------------------------------- /demo/shouldPass/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | mavenLocal() // allows picking local maven versions of classpath-hell 4 | gradlePluginPortal() 5 | } 6 | } 7 | 8 | rootProject.name = 'classpathHell-sample' 9 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/portingle/classpathHell/fe68d420ab08d1e7edd5da5298900e5c3d833598/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 87 | 88 | # Use the maximum available, or set MAX_FD != -1 to use that value. 89 | MAX_FD=maximum 90 | 91 | warn () { 92 | echo "$*" 93 | } >&2 94 | 95 | die () { 96 | echo 97 | echo "$*" 98 | echo 99 | exit 1 100 | } >&2 101 | 102 | # OS specific support (must be 'true' or 'false'). 103 | cygwin=false 104 | msys=false 105 | darwin=false 106 | nonstop=false 107 | case "$( uname )" in #( 108 | CYGWIN* ) cygwin=true ;; #( 109 | Darwin* ) darwin=true ;; #( 110 | MSYS* | MINGW* ) msys=true ;; #( 111 | NONSTOP* ) nonstop=true ;; 112 | esac 113 | 114 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 115 | 116 | 117 | # Determine the Java command to use to start the JVM. 118 | if [ -n "$JAVA_HOME" ] ; then 119 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 120 | # IBM's JDK on AIX uses strange locations for the executables 121 | JAVACMD=$JAVA_HOME/jre/sh/java 122 | else 123 | JAVACMD=$JAVA_HOME/bin/java 124 | fi 125 | if [ ! -x "$JAVACMD" ] ; then 126 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 127 | 128 | Please set the JAVA_HOME variable in your environment to match the 129 | location of your Java installation." 130 | fi 131 | else 132 | JAVACMD=java 133 | if ! command -v java >/dev/null 2>&1 134 | then 135 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 136 | 137 | Please set the JAVA_HOME variable in your environment to match the 138 | location of your Java installation." 139 | fi 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 147 | # shellcheck disable=SC3045 148 | MAX_FD=$( ulimit -H -n ) || 149 | warn "Could not query maximum file descriptor limit" 150 | esac 151 | case $MAX_FD in #( 152 | '' | soft) :;; #( 153 | *) 154 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 155 | # shellcheck disable=SC3045 156 | ulimit -n "$MAX_FD" || 157 | warn "Could not set maximum file descriptor limit to $MAX_FD" 158 | esac 159 | fi 160 | 161 | # Collect all arguments for the java command, stacking in reverse order: 162 | # * args from the command line 163 | # * the main class name 164 | # * -classpath 165 | # * -D...appname settings 166 | # * --module-path (only if needed) 167 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 168 | 169 | # For Cygwin or MSYS, switch paths to Windows format before running java 170 | if "$cygwin" || "$msys" ; then 171 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 172 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 173 | 174 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 175 | 176 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 177 | for arg do 178 | if 179 | case $arg in #( 180 | -*) false ;; # don't mess with options #( 181 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 182 | [ -e "$t" ] ;; #( 183 | *) false ;; 184 | esac 185 | then 186 | arg=$( cygpath --path --ignore --mixed "$arg" ) 187 | fi 188 | # Roll the args list around exactly as many times as the number of 189 | # args, so each arg winds up back in the position where it started, but 190 | # possibly modified. 191 | # 192 | # NB: a `for` loop captures its iteration list before it begins, so 193 | # changing the positional parameters here affects neither the number of 194 | # iterations, nor the values presented in `arg`. 195 | shift # remove old arg 196 | set -- "$@" "$arg" # push replacement arg 197 | done 198 | fi 199 | 200 | 201 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 202 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 203 | 204 | # Collect all arguments for the java command; 205 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 206 | # shell script including quotes and variable substitutions, so put them in 207 | # double quotes to make sure that they get re-expanded; and 208 | # * put everything else in single quotes, so that it's not re-expanded. 209 | 210 | set -- \ 211 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 212 | -classpath "$CLASSPATH" \ 213 | org.gradle.wrapper.GradleWrapperMain \ 214 | "$@" 215 | 216 | # Stop when "xargs" is not available. 217 | if ! command -v xargs >/dev/null 2>&1 218 | then 219 | die "xargs is not available" 220 | fi 221 | 222 | # Use "xargs" to parse quoted args. 223 | # 224 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 225 | # 226 | # In Bash we could simply go: 227 | # 228 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 229 | # set -- "${ARGS[@]}" "$@" 230 | # 231 | # but POSIX shell has neither arrays nor command substitution, so instead we 232 | # post-process each arg (as a line of input to sed) to backslash-escape any 233 | # character that might be a shell metacharacter, then use eval to reverse 234 | # that process (while maintaining the separation between arguments), and wrap 235 | # the whole thing up as a single "set" statement. 236 | # 237 | # This will of course break if any of these variables contains a newline or 238 | # an unmatched quote. 239 | # 240 | 241 | eval "set -- $( 242 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 243 | xargs -n1 | 244 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 245 | tr '\n' ' ' 246 | )" '"$@"' 247 | 248 | exec "$JAVACMD" "$@" 249 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'classpath-hell' 2 | 3 | -------------------------------------------------------------------------------- /src/main/groovy/classpathHell/ClasspathHellPlugin.groovy: -------------------------------------------------------------------------------- 1 | package classpathHell 2 | 3 | import org.gradle.api.Plugin 4 | import org.gradle.api.Project 5 | 6 | class ClasspathHellPlugin implements Plugin { 7 | 8 | void apply(Project project) { 9 | project.extensions.create("classpathHell", ClasspathHellPluginExtension) 10 | 11 | project.task('checkClasspath', type: ClasspathHellTask) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/groovy/classpathHell/ClasspathHellPluginExtension.groovy: -------------------------------------------------------------------------------- 1 | package classpathHell 2 | 3 | import org.gradle.api.artifacts.Configuration 4 | import org.gradle.api.artifacts.ResolvedArtifact 5 | 6 | class ClasspathHellPluginExtension { 7 | 8 | /* set to true to get additional logging */ 9 | Boolean trace = false 10 | 11 | /* utility */ 12 | static boolean excludeArtifactPaths(List excludedPatterns, ResolvedArtifact f) { 13 | boolean inc = true 14 | excludedPatterns.each { String ex -> 15 | if (f.file.getAbsolutePath().matches(ex)) 16 | inc = false 17 | } 18 | 19 | return inc 20 | } 21 | 22 | /* override to supply a list of artifacts to exclude from the check (assuming includeArtifact has not been overridden). 23 | */ 24 | List artifactExclusions = [] 25 | 26 | /* optional list of configurations to limit the scan to */ 27 | List configurationsToScan = [] 28 | 29 | /* if true then instances of a resource that have the same hash will be considered equivalent and not be reported */ 30 | Boolean suppressExactDupes = false 31 | 32 | /* 33 | override to provide an alternative inclusion strategy to the default. 34 | */ 35 | Closure includeArtifact = { ResolvedArtifact f -> 36 | // default strategy is to exclude artifacts according to a black list 37 | excludeArtifactPaths(artifactExclusions.toList(), f) 38 | } 39 | 40 | /* utility */ 41 | static boolean excludeMatches(List excludedPatterns, String f) { 42 | 43 | boolean inc = true 44 | excludedPatterns.each { ex -> 45 | if (f.matches(ex)) 46 | inc = false 47 | } 48 | 49 | return inc 50 | } 51 | 52 | 53 | /* A convenience constant defining a set of common defaults that are not very interesting. 54 | */ 55 | static List CommonResourceExclusions() { 56 | return [ 57 | "^rootdoc.txt\$", 58 | "^about.html\$", 59 | "^NOTICE\$", 60 | "^LICENSE\$", 61 | "^LICENSE.*.txt\$", 62 | "^META-INF/.*", 63 | ".*/\$", 64 | ".*com/sun/.*", 65 | ".*javax/annotation/.*" 66 | ] } 67 | 68 | /** Optionally override or modify to provide an alternative list of resources to exclude from the check. 69 | */ 70 | List resourceExclusions = [] 71 | 72 | /* 73 | override to provide an alternative inclusion strategy to the default. 74 | */ 75 | Closure includeResource = { String f -> 76 | excludeMatches(resourceExclusions, f) 77 | } 78 | 79 | @Override 80 | String toString() { 81 | return "ClasspathHellPluginExtension{" + 82 | "artifactExclusions=" + artifactExclusions + 83 | ", configurationsToScan=" + configurationsToScan + 84 | ", includeArtifact=" + includeArtifact + 85 | ", resourceExclusions=" + resourceExclusions + 86 | ", includeResource=" + includeResource + 87 | '}' 88 | } 89 | } -------------------------------------------------------------------------------- /src/main/groovy/classpathHell/ClasspathHellTask.groovy: -------------------------------------------------------------------------------- 1 | package classpathHell 2 | 3 | import groovy.transform.PackageScope 4 | import org.gradle.api.DefaultTask 5 | import org.gradle.api.GradleException 6 | import org.gradle.api.InvalidUserDataException 7 | import org.gradle.api.artifacts.Configuration 8 | import org.gradle.api.artifacts.ResolvedArtifact 9 | import org.gradle.api.artifacts.ResolvedDependency 10 | import org.gradle.api.logging.LogLevel 11 | import org.gradle.api.tasks.InputFiles 12 | import org.gradle.api.tasks.OutputFiles 13 | import org.gradle.api.tasks.TaskAction 14 | 15 | import java.security.DigestInputStream 16 | import java.security.MessageDigest 17 | import java.util.regex.Pattern 18 | import java.util.zip.ZipEntry 19 | import java.util.zip.ZipException 20 | import java.util.zip.ZipFile 21 | 22 | @PackageScope 23 | class ClasspathHellTask extends DefaultTask { 24 | 25 | static Collection getResourcesFromJarFile(final File jarFile, final Pattern pattern) { 26 | 27 | final ArrayList resourceFiles = new ArrayList() 28 | 29 | ZipFile zf = new ZipFile(jarFile) 30 | final Enumeration e = zf.entries() 31 | 32 | while (e.hasMoreElements()) { 33 | final ZipEntry ze = (ZipEntry) e.nextElement() 34 | 35 | final String resourceFileName = ze.getName() 36 | final boolean accept = pattern.matcher(resourceFileName).matches() 37 | if (accept) { 38 | resourceFiles.add(resourceFileName) 39 | } 40 | } 41 | zf.close() 42 | 43 | return resourceFiles 44 | } 45 | 46 | static String gav(ResolvedDependency d) { 47 | def f = d.module.id 48 | f.group + ":" + f.name + ":" + f.version 49 | } 50 | 51 | static Set findDep(List pathAccumulator, ResolvedDependency dep, ResolvedArtifact source) { 52 | if (dep.module.id == source.moduleVersion.id) { 53 | List path = pathAccumulator.reverse().collect { it.toString() } 54 | 55 | def s = [] as Set 56 | s.add path.join(" <- ") 57 | return s 58 | } else { 59 | Set children = dep.children 60 | return findDepC(pathAccumulator, children, source) 61 | } 62 | } 63 | 64 | 65 | static Set findDepC(List pathAccumulator, Set children, ResolvedArtifact source) { 66 | def found = [] as Set 67 | 68 | children.each { child -> 69 | def newAccum = new ArrayList(pathAccumulator) 70 | newAccum.add(gav(child)) 71 | def f = findDep(newAccum, child, source) 72 | found.addAll(f) 73 | } 74 | found 75 | } 76 | 77 | static Set findRoute(Configuration conf, ResolvedArtifact source) { 78 | Set deps = conf.getResolvedConfiguration().firstLevelModuleDependencies 79 | findDepC([], deps, source) 80 | } 81 | 82 | private static String getHexaString(byte[] data) { 83 | String result = new BigInteger(1, data).toString(16) 84 | return result 85 | } 86 | 87 | 88 | private static String getHashOfStream(InputStream stream) { 89 | def instance = MessageDigest.getInstance("MD5") 90 | 91 | DigestInputStream digestInputStream = new DigestInputStream(stream, instance) 92 | byte[] buffer = new byte[4096] 93 | while (digestInputStream.read(buffer) > -1) { 94 | // pass 95 | } 96 | MessageDigest md1 = digestInputStream.getMessageDigest() 97 | byte[] digestBytes = md1.digest() 98 | def digestStr = getHexaString(digestBytes) 99 | return digestStr 100 | } 101 | 102 | Set suppressPermittedCombinations(boolean suppressByHash, String resourcePath, Set dupes, Closure trace) { 103 | if (!suppressByHash) return dupes 104 | 105 | Set hashes = new HashSet() 106 | Set ids = new HashSet() 107 | dupes.each { file -> 108 | ZipFile zf = new ZipFile(file.file) 109 | ZipEntry ze = zf.getEntry(resourcePath) 110 | InputStream zi = zf.getInputStream(ze) 111 | 112 | String md5 = ClasspathHellTask.getHashOfStream(zi) 113 | hashes.add(md5) 114 | 115 | trace(" " + resourcePath + " #md5 " + md5 + " @ " + file.id.componentIdentifier) 116 | 117 | ids.add(file.id.componentIdentifier.toString()) 118 | 119 | zi.close() 120 | } 121 | 122 | if (hashes.size() == 1) { 123 | trace(" " + resourcePath + " has been automatically suppressed across : " + ids) 124 | 125 | return new HashSet() 126 | } 127 | 128 | return dupes 129 | } 130 | 131 | List getResources(File location) { 132 | ArrayList files = new ArrayList() 133 | if (location.isFile()) { 134 | try { 135 | if (location.name.toLowerCase().endsWith(".jar") || location.name.toLowerCase().endsWith(".zip")) { 136 | // should be a valid zip/jar 137 | Collection resourcesInJar = getResourcesFromJarFile(location, Pattern.compile(".*")) 138 | files.addAll(resourcesInJar) 139 | } else { 140 | // might still be an archive I suppose so try unzipping anyway 141 | try { 142 | Collection resourcesInJar = getResourcesFromJarFile(location, Pattern.compile(".*")) 143 | files.addAll(resourcesInJar) 144 | } catch (ZipException x) { 145 | // definitely wasn't a valid zip/jar 146 | files.addAll(location.getPath()) 147 | } 148 | } 149 | } catch (Exception ex) { 150 | logger.warn("classpathHell: error processing " + location, ex) 151 | throw ex 152 | } 153 | } else if (location.isDirectory()) { 154 | // dir 155 | location.listFiles().each { File fileOrDir -> 156 | Collection resourcesInJar = getResources(fileOrDir) 157 | 158 | files.addAll(resourcesInJar) 159 | } 160 | } else { 161 | logger.warn("classpathHell: skipping location as is neither a file nor a directory " + location) 162 | } 163 | return files 164 | } 165 | 166 | //https://docs.gradle.org/current/userguide/dependency_management.html#sec:resolvable-consumable-configs 167 | static boolean canBeResolved(configuration) { 168 | // Configuration.isCanBeResolved() has been introduced with Gradle 3.3, 169 | // thus we need to check for the method's existence first 170 | configuration.metaClass.respondsTo(configuration, "isCanBeResolved") ? configuration.isCanBeResolved() : true 171 | } 172 | 173 | @TaskAction 174 | void action() { 175 | 176 | ClasspathHellPluginExtension ext = project.classpathHell 177 | 178 | boolean doTrace = ext.trace 179 | if (project.hasProperty('classpathHell.trace')) { 180 | doTrace = Boolean.valueOf(project['classpathHell.trace'].toString()) 181 | } 182 | if (project.logging.getLevel() == LogLevel.DEBUG) { 183 | doTrace = true 184 | } 185 | 186 | logger.info("classpathHell: trace=" + doTrace) 187 | if (doTrace && !(project.logging.getLevel() == null || 188 | project.logging.getLevel() == LogLevel.INFO || 189 | project.logging.getLevel() == LogLevel.DEBUG)) 190 | logger.warn("classpathHell: 'trace=true' however nothing will be shown unless the log level is --info or --debug") 191 | 192 | def trace = { 193 | String s -> 194 | if (doTrace) logger.info("classpathHell: " + s) 195 | } 196 | 197 | boolean hadDupes = false 198 | 199 | def configurations = getConfigurations(ext) 200 | checkThatAllConfigurationsAreResolvable(configurations) 201 | 202 | configurations.each { Configuration conf -> 203 | logger.info("classpathHell: checking configuration : '" + conf.getName() + "'") 204 | 205 | Map> resourceToSource = new HashMap() 206 | conf.getResolvedConfiguration().getResolvedArtifacts().each { 207 | ResolvedArtifact resolvedArtifact -> 208 | 209 | if (ext.includeArtifact.call(resolvedArtifact)) { 210 | trace("including artifact <" + resolvedArtifact.moduleVersion.id + ">") 211 | 212 | File file = resolvedArtifact.file 213 | def resourcesInFile = getResources(file) 214 | Collection includedResources = resourcesInFile.findAll { 215 | String res -> 216 | Boolean inc = ext.includeResource.call(res) 217 | 218 | if (inc) trace(" including resource <" + res + ">") 219 | else trace(" excluding resource <" + res + ">") 220 | inc 221 | } 222 | 223 | // collect resources into a map of resourceName to source of resource 224 | includedResources.each { res -> 225 | Set sources = resourceToSource.get(res) 226 | if (!resourceToSource.containsKey(res)) { 227 | sources = new HashSet() 228 | resourceToSource.put(res, sources) 229 | } 230 | sources.add(resolvedArtifact) 231 | } 232 | } else 233 | trace("excluding artifact <" + resolvedArtifact.moduleVersion.id + ">") 234 | 235 | } 236 | 237 | resourceToSource.entrySet().each { Map.Entry> e -> 238 | String resourcePath = e.key 239 | trace("checking resource : " + resourcePath) 240 | 241 | Set sources = e.value 242 | if (sources.size() > 1) { 243 | Set dupes = suppressPermittedCombinations(ext.suppressExactDupes, resourcePath, sources, trace) 244 | 245 | boolean thisHasDupes = !dupes.isEmpty() 246 | 247 | if (thisHasDupes) { 248 | System.err.println("configuration '" + conf.name + "' contains duplicate resource: " + resourcePath) 249 | 250 | dupes.toList().sort().each { source -> 251 | System.err.println(" found within dependency: " + source.moduleVersion.id) 252 | findRoute(conf, source).toList().sort().each { route -> 253 | System.err.println(" imported via: " + route) 254 | } 255 | } 256 | } 257 | 258 | if (thisHasDupes) hadDupes = true 259 | } 260 | } 261 | } 262 | 263 | if (hadDupes) 264 | throw new GradleException("Duplicate resources detected") 265 | } 266 | 267 | private List getConfigurations(ClasspathHellPluginExtension ext) { 268 | def configurations = ext.configurationsToScan 269 | if (!configurations) { 270 | logger.info("classpathHell: no configurationsToScan specified so will scan all configurations ") 271 | configurations = this.project.getConfigurations().findAll(it -> canBeResolved(it)).toList() 272 | } 273 | logger.info("classpathHell: candidate configurations : " + configurations.collect(it -> it.name)) 274 | return configurations 275 | } 276 | 277 | private checkThatAllConfigurationsAreResolvable(List configurations) { 278 | def nonResolvableConfigurations = configurations.findAll { 279 | !canBeResolved(it) 280 | } 281 | 282 | if (nonResolvableConfigurations.size() > 0) { 283 | def err = nonResolvableConfigurations.collect(it -> 284 | ("classpathHell: configuration '" + it.name + "' is not resolvable") 285 | ).join("\n") 286 | 287 | def hint = "classpathHell: the resolvable configurations are: " + 288 | project.configurations.findAll(it -> canBeResolved(it)).collect(it -> it.name) 289 | def link = "classpathHell: for more information on 'resolvable configurations' " + 290 | "see https://docs.gradle.org/current/userguide/dependency_management.html#sec:resolvable-consumable-configs" 291 | 292 | logger.error(err) 293 | logger.error(hint) 294 | logger.error(link) 295 | throw new InvalidUserDataException(err + "\n" + hint + "\n" + link) 296 | } 297 | 298 | } 299 | 300 | // Determine the effective dependency files. 301 | // If these change checkClasspath has work to do. 302 | // Otherwise it can be skipped. 303 | @InputFiles 304 | List getInputFiles() { 305 | 306 | ClasspathHellPluginExtension ext = project.classpathHell 307 | def configurations = getConfigurations(ext) 308 | def resolvableConfigurations = configurations.findAll { 309 | canBeResolved(it) 310 | } 311 | 312 | def inputFiles = [] 313 | resolvableConfigurations.each { Configuration conf -> 314 | conf.getResolvedConfiguration().getResolvedArtifacts().each { 315 | ResolvedArtifact resolvedArtifact -> 316 | if (ext.includeArtifact.call(resolvedArtifact)) { 317 | inputFiles += resolvedArtifact.file 318 | } 319 | } 320 | } 321 | 322 | return inputFiles 323 | } 324 | 325 | @OutputFiles 326 | List getOutputFiles() { 327 | return getInputFiles(); 328 | } 329 | 330 | } 331 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/gradle-plugins/com.portingle.classpathHell.properties: -------------------------------------------------------------------------------- 1 | implementation-class=classpathHell.ClasspathHellPlugin -------------------------------------------------------------------------------- /src/test/groovy/classpathHell/ClasspathHellPluginTests.groovy: -------------------------------------------------------------------------------- 1 | package classpathHell 2 | 3 | import org.gradle.api.Project 4 | import org.gradle.testfixtures.ProjectBuilder 5 | import org.gradle.testkit.runner.BuildResult 6 | import org.gradle.testkit.runner.GradleRunner 7 | import org.junit.Before 8 | import org.junit.FixMethodOrder 9 | import org.junit.Rule 10 | import org.junit.Test 11 | import org.junit.rules.TemporaryFolder 12 | import org.junit.runners.MethodSorters 13 | 14 | import java.nio.file.Paths 15 | 16 | import static org.gradle.testkit.runner.TaskOutcome.FAILED 17 | import static org.gradle.testkit.runner.TaskOutcome.SUCCESS 18 | import static org.gradle.testkit.runner.TaskOutcome.UP_TO_DATE 19 | import static org.junit.Assert.* 20 | 21 | @FixMethodOrder(MethodSorters.NAME_ASCENDING) 22 | class ClasspathHellPluginTests { 23 | @Rule 24 | public final TemporaryFolder testProjectDir = new TemporaryFolder() 25 | 26 | private File buildFile 27 | private File propertiesFile 28 | 29 | private List pluginClasspath 30 | 31 | @Before 32 | void setup() throws IOException { 33 | 34 | buildFile = testProjectDir.newFile('build.gradle') 35 | propertiesFile = testProjectDir.newFile('gradle.properties') 36 | URL classpathResource = getClass().classLoader.getResource('plugin-classpath.txt') 37 | if (classpathResource == null) 38 | throw new RuntimeException('Cannot find plugin-classpath.txt - did the gradle build correctly create it - run the "createPluginClasspath" task?') 39 | pluginClasspath = classpathResource.readLines().collect { new File(it) } 40 | } 41 | 42 | @Test 43 | void testCanApplySettings() { 44 | Project project = ProjectBuilder.builder().build() 45 | project.pluginManager.apply 'com.portingle.classpathHell' 46 | 47 | assertTrue(project.tasks.checkClasspath instanceof ClasspathHellTask) 48 | 49 | ClasspathHellTask task = (ClasspathHellTask) project.tasks.checkClasspath 50 | task.action() 51 | } 52 | 53 | @Test 54 | void testScansConfigurations() { 55 | buildFile << ''' 56 | plugins { 57 | id 'com.portingle.classpathHell' 58 | } 59 | 60 | configurations { 61 | config1 62 | config2 63 | } 64 | ''' 65 | 66 | GradleRunner runner = GradleRunner.create() 67 | .forwardOutput() 68 | .withProjectDir(testProjectDir.getRoot()) 69 | .withArguments('--info', 'checkClasspath', '--stacktrace', '--refresh-dependencies') 70 | .withPluginClasspath(pluginClasspath) 71 | BuildResult result = runner.build() 72 | 73 | assertTrue(result.getOutput().contains("checking configuration : 'config1'")) 74 | assertTrue(result.getOutput().contains("checking configuration : 'config2'")) 75 | assertEquals(result.task(":checkClasspath").getOutcome(), SUCCESS) 76 | } 77 | 78 | @Test 79 | void testScansSelectiveConfigurations() { 80 | buildFile << ''' 81 | plugins { 82 | id 'com.portingle.classpathHell' 83 | } 84 | 85 | configurations { 86 | config1 87 | config2 88 | } 89 | 90 | classpathHell { 91 | 92 | artifactExclusions = [ ".*hamcrest-all.*" ] 93 | 94 | configurationsToScan = [ configurations.config2 ] 95 | 96 | } 97 | 98 | ''' 99 | 100 | GradleRunner runner = GradleRunner.create() 101 | .forwardOutput() 102 | .withProjectDir(testProjectDir.getRoot()) 103 | .withArguments('--info', 'checkClasspath', '--stacktrace', '--refresh-dependencies') 104 | .withPluginClasspath(pluginClasspath) 105 | BuildResult result = runner.build() 106 | 107 | assertFalse(result.getOutput().contains("checking configuration : 'config1'")) 108 | assertTrue(result.getOutput().contains("checking configuration : 'config2'")) 109 | assertEquals(result.task(":checkClasspath").getOutcome(), SUCCESS) 110 | } 111 | 112 | @Test 113 | void testReportsDupes() { 114 | buildFile << ''' 115 | plugins { 116 | id 'com.portingle.classpathHell' 117 | } 118 | apply plugin: 'java' 119 | 120 | repositories { 121 | mavenCentral() 122 | } 123 | dependencies { 124 | implementation group: 'org.hamcrest', name: 'hamcrest-all', version: '1.3' 125 | implementation group: 'org.hamcrest', name: 'hamcrest-core', version: '1.3' 126 | } 127 | 128 | classpathHell { 129 | trace = true 130 | configurationsToScan = [ configurations.compileClasspath ] 131 | } 132 | 133 | ''' 134 | 135 | GradleRunner runner = GradleRunner.create() 136 | .forwardOutput() 137 | .withProjectDir(testProjectDir.getRoot()) 138 | .withArguments("--info", 'checkClasspath') 139 | .withPluginClasspath(pluginClasspath) 140 | 141 | BuildResult result = runner.buildAndFail() 142 | 143 | def output = result.getOutput() 144 | assertTrue(output.contains("configuration 'compileClasspath' contains duplicate resource: org/hamcrest/core/CombinableMatcher")) 145 | assertEquals(result.task(":checkClasspath").getOutcome(), FAILED) 146 | } 147 | 148 | @Test 149 | void testReportsNonResolvableProfile() { 150 | buildFile << ''' 151 | plugins { 152 | id 'com.portingle.classpathHell' 153 | } 154 | apply plugin: 'java' 155 | 156 | repositories { 157 | mavenCentral() 158 | } 159 | dependencies { 160 | implementation group: 'org.hamcrest', name: 'hamcrest-all', version: '1.3' 161 | implementation group: 'org.hamcrest', name: 'hamcrest-core', version: '1.3' 162 | } 163 | 164 | classpathHell { 165 | trace = true 166 | configurationsToScan = [ configurations.implementation ] 167 | } 168 | 169 | ''' 170 | 171 | GradleRunner runner = GradleRunner.create() 172 | .forwardOutput() 173 | .withProjectDir(testProjectDir.getRoot()) 174 | .withArguments("--info", 'checkClasspath') 175 | .withPluginClasspath(pluginClasspath) 176 | 177 | BuildResult result = runner.buildAndFail() 178 | 179 | def output = result.getOutput() 180 | assertTrue(output.contains("configuration 'implementation' is not resolvable")) 181 | assertEquals(result.task(":checkClasspath").getOutcome(), FAILED) 182 | } 183 | 184 | @Test 185 | void testASuppressionOfExactDupes() { 186 | buildFile << ''' 187 | plugins { 188 | id 'com.portingle.classpathHell' 189 | } 190 | apply plugin: 'java' 191 | 192 | repositories { 193 | flatDir { 194 | dirs "${project.rootDir}/tmpRepo" 195 | } 196 | mavenCentral() 197 | } 198 | 199 | dependencies { 200 | implementation group: 'org.hamcrest', name: 'hamcrest-all', version: '1.3' 201 | implementation group: 'org.hamcrest', name: 'hamcrest-core', version: '1.3' 202 | } 203 | 204 | classpathHell { 205 | // some additional logging; note one must also run with "--info" or "--debug" if you want to see this detail. 206 | // specify the property as shown below or set the gradle property -PclasspathHell.trace=true 207 | trace = true 208 | 209 | // only scan one configuration as this makes the test's verification easier 210 | //configurationsToScan = [ configurations.compileClasspath] 211 | 212 | resourceExclusions = [ "META-INF/MANIFEST.MF" ] 213 | 214 | // configure automatic resolution of "benign" dupes 215 | suppressExactDupes = true 216 | } 217 | 218 | ''' 219 | 220 | GradleRunner runner = GradleRunner.create() 221 | .forwardOutput() 222 | .withProjectDir(testProjectDir.getRoot()) 223 | .withArguments('--info', 'checkClasspath') 224 | .withPluginClasspath(pluginClasspath) 225 | 226 | File pd = runner.getProjectDir() 227 | 228 | new java.io.File(pd.getAbsolutePath() + "/tmpRepo").mkdirs() 229 | new java.io.File(pd.getAbsolutePath() + "/tmpRepo/ping-999.pong").write("NOT A JAR") 230 | 231 | BuildResult result = runner.build() // expect success 232 | 233 | def output = result.getOutput() 234 | 235 | // check suppression trace 236 | assertTrue(output.contains("org/hamcrest/core/CombinableMatcher\$CombinableBothMatcher.class has been automatically suppressed across : [org.hamcrest:hamcrest-all:1.3, org.hamcrest:hamcrest-core:1.3]")) 237 | assertTrue(output.contains('org/hamcrest/core/CombinableMatcher\$CombinableBothMatcher.class #md5')) 238 | } 239 | 240 | @Test 241 | void testDontUnzipNonZipFiles() { 242 | buildFile << ''' 243 | plugins { 244 | id 'com.portingle.classpathHell' 245 | } 246 | apply plugin: 'java' 247 | 248 | repositories { 249 | flatDir { 250 | dirs "${project.rootDir}/tmpRepo" 251 | } 252 | mavenCentral() 253 | } 254 | 255 | dependencies { 256 | implementation('jl:ping:999') { 257 | artifact { 258 | name = 'ping' 259 | extension = 'pong' 260 | type = 'ttt' 261 | } 262 | } 263 | } 264 | 265 | classpathHell { 266 | // some additional logging; note one must also run with "--info" or "--debug" if you want to see this detail. 267 | // specify the property as shown below or set the gradle property -PclasspathHell.trace=true 268 | trace = true 269 | 270 | // only scan one configuration as this makes the test's verification easier 271 | //configurationsToScan = [ configurations.compileClasspath] 272 | 273 | resourceExclusions = [ "META-INF/MANIFEST.MF" ] 274 | 275 | // configure automatic resolution of "benign" dupes 276 | suppressExactDupes = true 277 | } 278 | 279 | ''' 280 | 281 | GradleRunner runner = GradleRunner.create() 282 | .forwardOutput() 283 | .withProjectDir(testProjectDir.getRoot()) 284 | .withArguments('--info', 'checkClasspath') 285 | .withPluginClasspath(pluginClasspath) 286 | 287 | File pd = runner.getProjectDir() 288 | 289 | Paths.get(pd.getAbsolutePath(), "tmpRepo").toFile().mkdirs() 290 | Paths.get(pd.getAbsolutePath(), "tmpRepo", "ping-999.pong").write("NOT A JAR") 291 | 292 | BuildResult result = runner.build() // expect success 293 | 294 | def output = result.getOutput() 295 | 296 | // check suppression trace 297 | assertTrue(output.contains("classpathHell: including artifact ")) 298 | assertTrue(output.matches("(?s).*classpathHell:.*including resource <.*tmpRepo.*ping-999.pong>.*")) 299 | } 300 | 301 | @Test 302 | void testOverrideExtensions() { 303 | buildFile << ''' 304 | plugins { 305 | id 'com.portingle.classpathHell' 306 | } 307 | apply plugin: 'java' 308 | 309 | repositories { 310 | mavenCentral() 311 | } 312 | 313 | classpathHell { 314 | trace = true 315 | 316 | artifactExclusions = [ ".*hamcrest-all.*" ] 317 | 318 | // Demonstrate replacing the default implementation of the rule 319 | includeArtifact = { 320 | artifact -> 321 | println("OVERRIDE includeArtifact CHECK OF " + artifact) 322 | 323 | excludeArtifactPaths(artifactExclusions, artifact) 324 | } 325 | 326 | // Demonstrate replacing the default set of exclusions 327 | resourceExclusions = [ ".*/BaseMatcher.class" ] 328 | 329 | // scan a only one config so that test logs less 330 | configurationsToScan = [ configurations.compileClasspath] 331 | } 332 | 333 | dependencies { 334 | implementation group: 'org.hamcrest', name: 'hamcrest-all', version: '1.3' 335 | implementation group: 'org.hamcrest', name: 'hamcrest-core', version: '1.3' 336 | } 337 | ''' 338 | 339 | GradleRunner runner = GradleRunner.create() 340 | .forwardOutput() 341 | .withProjectDir(testProjectDir.getRoot()) 342 | .withArguments('--info', 'checkClasspath') 343 | .withPluginClasspath(pluginClasspath) 344 | 345 | // should pass as we've removed the dup artifact 346 | BuildResult result = runner.build() 347 | 348 | assertTrue(result.getOutput().contains('including artifact ')) 349 | assertTrue(result.getOutput().contains('excluding artifact ')) 350 | assertTrue(result.getOutput().contains('excluding resource ')) 351 | assertTrue(result.getOutput().contains('OVERRIDE includeArtifact CHECK OF')) 352 | } 353 | 354 | /** 355 | * Tests the usage of the configured configurations as in- and outputs of the task. 356 | * Unchanged dependencies producing no error should result in a skipped / up-to-date task. 357 | */ 358 | @Test 359 | void testTaskIsSkippedIfDependenciesDoNotChange() { 360 | writeBuildFile("1.3", false) 361 | 362 | GradleRunner runner = GradleRunner.create() 363 | .forwardOutput() 364 | .withProjectDir(testProjectDir.getRoot()) 365 | .withArguments("--info", 'checkClasspath') 366 | .withPluginClasspath(pluginClasspath) 367 | 368 | println("===================================== FIRST RUN") 369 | BuildResult result = runner.build() 370 | assertTrue(result.getOutput().contains('classpathHell: checking resource')) 371 | assertEquals("First run should result in SUCCESS", SUCCESS, result.task(":checkClasspath").getOutcome()) 372 | 373 | // second run: Task should be skipped since dependencies did not change: 374 | 375 | println("===================================== SECOND RUN") 376 | result = runner.build() 377 | assertFalse(result.getOutput().contains('classpathHell: checking resource')) 378 | assertEquals("Second run should result in UP_TO_DATE", UP_TO_DATE, result.task(":checkClasspath").getOutcome()) 379 | 380 | } 381 | 382 | /** 383 | * Tests the usage of the configured configurations as in- and outputs of the task. 384 | * Changed dependencies should result in a second complete check. 385 | */ 386 | @Test 387 | void testTaskIsNotSkippedIfDependenciesChange() { 388 | writeBuildFile("1.1", false) 389 | 390 | GradleRunner runner = GradleRunner.create() 391 | .forwardOutput() 392 | .withProjectDir(testProjectDir.getRoot()) 393 | .withArguments("--info", 'checkClasspath') 394 | .withPluginClasspath(pluginClasspath) 395 | 396 | println("===================================== FIRST RUN") 397 | BuildResult result = runner.build() 398 | assertTrue(result.getOutput().contains('classpathHell: checking resource')) 399 | assertEquals("First run should result in SUCCESS", SUCCESS, result.task(":checkClasspath").getOutcome()) 400 | 401 | // Change dependencies for second run (upgrade hamcrest dependency) 402 | writeBuildFile("1.3", false) 403 | 404 | // second run: Task should be skipped since dependencies did not change: 405 | println("===================================== SECOND RUN") 406 | result = runner.build() 407 | assertTrue(result.getOutput().contains('classpathHell: checking resource')) 408 | assertEquals("Second run should result in SUCCESS", SUCCESS, result.task(":checkClasspath").getOutcome()) 409 | } 410 | 411 | /** 412 | * Tests the usage of the configured configurations as in- and outputs of the task. 413 | * If the previous run encountered duplicates the task should not be skipped, even if 414 | * nothing changed. 415 | */ 416 | @Test 417 | void testTaskIsNotSkippedIfDuplicatesExist() { 418 | writeBuildFile("1.3", true) 419 | 420 | GradleRunner runner = GradleRunner.create() 421 | .forwardOutput() 422 | .withProjectDir(testProjectDir.getRoot()) 423 | .withArguments("--info", 'checkClasspath') 424 | .withPluginClasspath(pluginClasspath) 425 | 426 | println("===================================== FIRST RUN") 427 | BuildResult result = runner.buildAndFail() 428 | assertTrue(result.getOutput().contains('classpathHell: checking resource')) 429 | assertEquals("First run should result in FAILED", FAILED, result.task(":checkClasspath").getOutcome()) 430 | 431 | // second run: task should not be skipped since the last run failed!: 432 | println("===================================== SECOND RUN") 433 | result = runner.buildAndFail() 434 | assertTrue(result.getOutput().contains('classpathHell: checking resource')) 435 | assertEquals("Second run should result in FAILED", FAILED, result.task(":checkClasspath").getOutcome()) 436 | 437 | } 438 | 439 | /** 440 | * Overwrites the existing build file with a file including a single hamcrest-all dependency, using 441 | * the provided version from the parameter. 442 | * 443 | * @param hamcrestVersion the version of hamcrest-all to write to the build file. 444 | */ 445 | private void writeBuildFile(String hamcrestVersion, boolean includeHamcrestCore) { 446 | buildFile.withWriter { writer -> 447 | writer << """ 448 | plugins { 449 | id 'com.portingle.classpathHell' 450 | } 451 | apply plugin: 'java' 452 | 453 | repositories { 454 | mavenCentral() 455 | } 456 | dependencies { 457 | implementation group: 'org.hamcrest', name: 'hamcrest-all', version: '$hamcrestVersion' 458 | ${if (includeHamcrestCore) { 459 | " implementation group: 'org.hamcrest', name: 'hamcrest-core', version: '$hamcrestVersion'" 460 | }} 461 | 462 | } 463 | 464 | classpathHell { 465 | trace = true 466 | configurationsToScan = [ configurations.compileClasspath ] 467 | } 468 | 469 | """ 470 | } 471 | } 472 | } 473 | --------------------------------------------------------------------------------