-
Notifications
You must be signed in to change notification settings - Fork 135
Plugin to validate all classes on the classpath are uniquely named #267
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 26 commits
77d0413
8c38f0c
d94f939
7941840
19b30c2
5ce2ee8
c9c3b55
0919961
9d0745b
a81b6b3
b66f48c
a1275df
ed4425e
5ea80f6
a1b2156
982e92d
bcf86e8
cfa7aec
830af37
7784164
9e9ab49
4591774
ea7cd0b
67264ff
27b793c
8692c46
e676bb2
94c6b38
4f1f8c8
f6b4ebd
9f4963d
c17aa36
73bf75f
3d5c250
778c4fa
2bdcc50
087003f
2ac6526
f3e98d4
452a0b6
4526967
4640a4d
caaa580
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| /* | ||
| * (c) Copyright 2018 Palantir Technologies Inc. All rights reserved. | ||
| * | ||
| * 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 com.palantir.baseline.plugins; | ||
|
|
||
| import com.palantir.baseline.tasks.CheckClassUniquenessTask; | ||
| import org.gradle.api.Project; | ||
|
|
||
| @SuppressWarnings("checkstyle:designforextension") // making this 'final' breaks gradle | ||
|
||
| public class BaselineClassUniquenessPlugin extends AbstractBaselinePlugin { | ||
|
|
||
| @Override | ||
| public void apply(Project project) { | ||
| project.getPlugins().withId("java", plugin -> { | ||
| project.getTasks().create("checkClassUniqueness", CheckClassUniquenessTask.class, task -> { | ||
| task.setConfiguration(project.getConfigurations().getByName("testRuntime")); | ||
| project.getTasks().getByName("check").dependsOn(task); | ||
| }); | ||
| }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| /* | ||
| * (c) Copyright 2018 Palantir Technologies Inc. All rights reserved. | ||
| * | ||
| * 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 com.palantir.baseline.tasks; | ||
|
|
||
| import java.io.File; | ||
| import java.io.IOException; | ||
| import java.nio.charset.StandardCharsets; | ||
| import java.nio.file.Files; | ||
| import java.nio.file.Paths; | ||
| import java.time.Duration; | ||
| import java.time.Instant; | ||
| import java.util.Collection; | ||
| import java.util.Enumeration; | ||
| import java.util.HashMap; | ||
| import java.util.HashSet; | ||
| import java.util.Map; | ||
| import java.util.Set; | ||
| import java.util.concurrent.ConcurrentHashMap; | ||
| import java.util.concurrent.CopyOnWriteArrayList; | ||
| import java.util.jar.JarEntry; | ||
| import java.util.jar.JarFile; | ||
| import org.gradle.api.DefaultTask; | ||
| import org.gradle.api.artifacts.Configuration; | ||
| import org.gradle.api.artifacts.ModuleVersionIdentifier; | ||
| import org.gradle.api.artifacts.ResolvedArtifact; | ||
| import org.gradle.api.tasks.Input; | ||
| import org.gradle.api.tasks.OutputFile; | ||
| import org.gradle.api.tasks.TaskAction; | ||
|
|
||
| @SuppressWarnings("checkstyle:designforextension") // making this 'final' breaks gradle | ||
|
||
| public class CheckClassUniquenessTask extends DefaultTask { | ||
|
|
||
| private Configuration configuration; | ||
|
|
||
| public CheckClassUniquenessTask() { | ||
| setGroup("Verification"); | ||
| setDescription("Checks that the given configuration contains no identically named classes."); | ||
| } | ||
|
|
||
| @Input | ||
| public Configuration getConfiguration() { | ||
| return configuration; | ||
| } | ||
|
|
||
| public void setConfiguration(Configuration configuration) { | ||
| this.configuration = configuration; | ||
| } | ||
|
|
||
| @TaskAction | ||
| public void checkForDuplicateClasses() { | ||
| Map<String, Collection<ModuleVersionIdentifier>> classToJarMap = constructClassNameToSourceJarMap(); | ||
|
|
||
| Map<Set<ModuleVersionIdentifier>, Collection<String>> jarsToOverlappingClasses = new HashMap<>(); | ||
| classToJarMap.forEach((className, sourceJars) -> { | ||
| if (sourceJars.size() > 1) { | ||
| addToMultiMap(jarsToOverlappingClasses, new HashSet<>(sourceJars), className); | ||
| } | ||
| }); | ||
|
|
||
| boolean success = jarsToOverlappingClasses.isEmpty(); | ||
| writeResultFile(success); | ||
|
|
||
| if (!success) { | ||
| jarsToOverlappingClasses.forEach((problemJars, classes) -> { | ||
| getLogger().error("Identically named classes found in {} jars ({}): {}", | ||
| problemJars.size(), problemJars, classes); | ||
| }); | ||
|
|
||
| throw new IllegalStateException(String.format( | ||
| "'%s' contains multiple copies of identically named classes - " | ||
| + "this may cause different runtime behaviour depending on classpath ordering.\n" | ||
| + "To resolve this, try excluding one of the following jars, " | ||
| + "changing a version or shadowing:\n\n\t%s", | ||
| configuration.getName(), | ||
| jarsToOverlappingClasses.keySet() | ||
| )); | ||
| } | ||
| } | ||
|
|
||
| private Map<String, Collection<ModuleVersionIdentifier>> constructClassNameToSourceJarMap() { | ||
| Map<String, Collection<ModuleVersionIdentifier>> classToJarMap = new ConcurrentHashMap<>(); | ||
|
|
||
| Instant before = Instant.now(); | ||
| Set<ResolvedArtifact> dependencies = getConfiguration() | ||
| .getResolvedConfiguration() | ||
| .getResolvedArtifacts(); | ||
|
|
||
| dependencies.parallelStream().forEach(resolvedArtifact -> { | ||
|
|
||
| File file = resolvedArtifact.getFile(); | ||
| if (!file.exists()) { | ||
| getLogger().info("Skipping non-existent jar {}: {}", resolvedArtifact, file); | ||
| return; | ||
| } | ||
|
|
||
| try (JarFile jarFile = new JarFile(file)) { | ||
| Enumeration<JarEntry> entries = jarFile.entries(); | ||
| while (entries.hasMoreElements()) { | ||
| JarEntry jarEntry = entries.nextElement(); | ||
|
|
||
| if (!jarEntry.getName().endsWith(".class")) { | ||
| continue; | ||
| } | ||
|
|
||
| addToMultiMap(classToJarMap, | ||
| jarEntry.getName().replaceAll("/", ".").replaceAll(".class", ""), | ||
| resolvedArtifact.getModuleVersion().getId()); | ||
| } | ||
| } catch (Exception e) { | ||
| getLogger().error("Failed to read JarFile {}, skipping...", resolvedArtifact, e); | ||
| throw new RuntimeException(e); | ||
| } | ||
| }); | ||
| Instant after = Instant.now(); | ||
|
|
||
| getLogger().info("Checked {} classes from {} dependencies for uniqueness ({}ms)", | ||
| classToJarMap.size(), dependencies.size(), Duration.between(before, after).toMillis()); | ||
|
|
||
| return classToJarMap; | ||
| } | ||
|
|
||
| /** | ||
| * This only exists to convince gradle this task is incremental. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. don't think this comment is strictly necessary -- I feel this is pretty standard/well understood for people who write gradle plugins
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's helpful to reassure readers of this code that the file we write really is inconsequential and not magically wired to something elsewhere in the project. |
||
| */ | ||
| @OutputFile | ||
| public File getResultFile() { | ||
| return getProject().getBuildDir().toPath() | ||
| .resolve(Paths.get("uniqueClassNames", configuration.getName())) | ||
| .toFile(); | ||
| } | ||
|
|
||
| private void writeResultFile(boolean success) { | ||
| try { | ||
| File result = getResultFile(); | ||
| Files.createDirectories(result.toPath().getParent()); | ||
| Files.write(result.toPath(), Boolean.toString(success).getBytes(StandardCharsets.UTF_8)); | ||
| } catch (IOException e) { | ||
| throw new RuntimeException("Unable to write boolean result file", e); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Implemented here so we don't pull in guava. Note that CopyOnWriteArrayList is threadsafe | ||
| * so we can parallelize elsewhere. | ||
| */ | ||
| private static <K, V> void addToMultiMap(Map<K, Collection<V>> multiMap, K key, V value) { | ||
| multiMap.compute(key, (unused, collection) -> { | ||
| Collection<V> newCollection = collection != null ? collection : new CopyOnWriteArrayList<>(); | ||
| newCollection.add(value); | ||
| return newCollection; | ||
| }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| implementation-class=com.palantir.baseline.plugins.BaselineClassUniquenessPlugin |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| /* | ||
| * (c) Copyright 2018 Palantir Technologies Inc. All rights reserved. | ||
| * | ||
| * 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 com.palantir.baseline | ||
|
|
||
| import java.nio.file.Files | ||
| import java.util.stream.Stream | ||
| import org.gradle.testkit.runner.BuildResult | ||
| import org.gradle.testkit.runner.TaskOutcome | ||
|
|
||
| class BaselineClassUniquenessPluginIntegrationTest extends AbstractPluginTest { | ||
|
|
||
| def standardBuildFile = """ | ||
| plugins { | ||
| id 'java' | ||
| id 'com.palantir.baseline-class-uniqueness' | ||
| } | ||
| subprojects { | ||
| apply plugin: 'java' | ||
| } | ||
| repositories { | ||
| mavenCentral() | ||
| } | ||
| """.stripIndent() | ||
|
|
||
| def 'Task should run as part of :check'() { | ||
| when: | ||
| buildFile << standardBuildFile | ||
|
|
||
| then: | ||
| def result = with('check', '--stacktrace').build() | ||
| result.task(':checkClassUniqueness').outcome == TaskOutcome.SUCCESS | ||
| } | ||
|
|
||
| def 'detect duplicates in two external jars'() { | ||
| when: | ||
| buildFile << standardBuildFile | ||
| buildFile << """ | ||
| dependencies { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i'd prefer to trim this test case down to a more minimal one
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Lots of entries here allows me to verify the sorted table works nicely. EDIT have thrown away the complicated sorting and split these unit tests. |
||
| compile group: 'javax.el', name: 'javax.el-api', version: '3.0.0' | ||
| compile group: 'javax.servlet.jsp', name: 'jsp-api', version: '2.1' | ||
| } | ||
| """.stripIndent() | ||
| BuildResult result = with('checkClassUniqueness').buildAndFail() | ||
|
|
||
| then: | ||
| result.getOutput().contains("Identically named classes found in 2 jars ([javax.servlet.jsp:jsp-api:2.1, javax.el:javax.el-api:3.0.0]): [javax.") | ||
| result.getOutput().contains("'testRuntime' contains multiple copies of identically named classes") | ||
| } | ||
|
|
||
| def 'task should be up-to-date when classpath is unchanged'() { | ||
| when: | ||
| buildFile << standardBuildFile | ||
|
|
||
| then: | ||
| BuildResult result1 = with('checkClassUniqueness').build() | ||
| result1.task(':checkClassUniqueness').outcome == TaskOutcome.SUCCESS | ||
|
|
||
| BuildResult result = with('checkClassUniqueness').build() | ||
| result.task(':checkClassUniqueness').outcome == TaskOutcome.UP_TO_DATE | ||
| } | ||
|
|
||
| def 'passes when no duplicates are present'() { | ||
| when: | ||
| buildFile << standardBuildFile | ||
| buildFile << """ | ||
| dependencies { | ||
| compile 'com.google.guava:guava:19.0' | ||
| compile 'org.apache.commons:commons-io:1.3.2' | ||
| compile 'junit:junit:4.12' | ||
| compile 'com.netflix.nebula:nebula-test:6.4.2' | ||
| } | ||
| """.stripIndent() | ||
| BuildResult result = with('checkClassUniqueness', '--info').build() | ||
|
|
||
| then: | ||
| result.task(":checkClassUniqueness").outcome == TaskOutcome.SUCCESS | ||
| println result.getOutput() | ||
| } | ||
|
|
||
| def 'should detect duplicates from transitive dependencies'() { | ||
| when: | ||
| multiProject.addSubproject('foo', """ | ||
| dependencies { | ||
| compile group: 'javax.el', name: 'javax.el-api', version: '3.0.0' | ||
| } | ||
| """) | ||
| multiProject.addSubproject('bar', """ | ||
| dependencies { | ||
| compile group: 'javax.servlet.jsp', name: 'jsp-api', version: '2.1' | ||
| } | ||
| """) | ||
|
|
||
| buildFile << standardBuildFile | ||
| buildFile << """ | ||
| dependencies { | ||
| compile project(':foo') | ||
| compile project(':bar') | ||
| } | ||
| """.stripIndent() | ||
|
|
||
| then: | ||
| BuildResult result = with('checkClassUniqueness').buildAndFail() | ||
| result.output.contains("Identically named classes found in 2 jars") | ||
| } | ||
|
|
||
| def 'currently skips duplicates from user-authored code'() { | ||
| when: | ||
| Stream.of(multiProject.addSubproject('foo'), multiProject.addSubproject('bar')).forEach({ subproject -> | ||
| File myClass = new File(subproject, "src/main/com/something/MyClass.java") | ||
| Files.createDirectories(myClass.toPath().getParent()) | ||
| myClass << "package com.something; class MyClass {}" | ||
| }) | ||
|
|
||
| buildFile << standardBuildFile | ||
| buildFile << """ | ||
| dependencies { | ||
| compile project(':foo') | ||
| compile project(':bar') | ||
| } | ||
| """.stripIndent() | ||
|
|
||
| then: | ||
| BuildResult result = with('checkClassUniqueness', '--info').build() | ||
| println result.getOutput() | ||
| result.task(":checkClassUniqueness").outcome == TaskOutcome.SUCCESS // ideally should should say failed! | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the plugin configures the task with the
runtimeconfiguration