-
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 31 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("runtime")); | ||
| project.getTasks().getByName("check").dependsOn(task); | ||
| }); | ||
| }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| /* | ||
| * (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.util.Comparator; | ||
| import java.util.Map; | ||
| import java.util.Set; | ||
| import java.util.TreeMap; | ||
| import java.util.stream.Collectors; | ||
| import org.gradle.api.DefaultTask; | ||
| import org.gradle.api.artifacts.Configuration; | ||
| import org.gradle.api.artifacts.ModuleVersionIdentifier; | ||
| 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() { | ||
| ClassUniquenessAnalyzer analyzer = new ClassUniquenessAnalyzer(getLogger()); | ||
| analyzer.analyzeConfiguration(getConfiguration()); | ||
| boolean success = analyzer.getProblemJars().isEmpty(); | ||
| writeResultFile(success); | ||
|
|
||
| if (!success) { | ||
| analyzer.getProblemJars().forEach((problemJars) -> { | ||
| Set<String> classes = analyzer.getDuplicateClassesInProblemJars(problemJars); | ||
| 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%s", | ||
|
||
| configuration.getName(), | ||
| formatSummary(analyzer) | ||
| )); | ||
| } | ||
| } | ||
|
|
||
| private static String formatSummary(ClassUniquenessAnalyzer summary) { | ||
| int maxLength = summary.jarsToClasses().keySet().stream().flatMap(Set::stream) | ||
| .map(ModuleVersionIdentifier::toString) | ||
| .map(String::length) | ||
| .max(Comparator.naturalOrder()).get(); | ||
| String format = "%-" + (maxLength + 1) + "s"; | ||
|
|
||
| Map<String, Integer> sortedTable = summary.jarsToClasses().entrySet().stream().collect(Collectors.toMap( | ||
| entry -> entry.getKey().stream().map(jar -> String.format(format, jar)).collect(Collectors.joining()), | ||
| entry -> entry.getValue().size(), | ||
| (first, second) -> { | ||
| throw new RuntimeException("Unexpected collision: " + first + ", " + second); | ||
| }, | ||
| TreeMap::new)); | ||
|
|
||
| StringBuilder builder = new StringBuilder(); | ||
| sortedTable.forEach((jars, classes) -> | ||
| builder.append(String.format("\t%-14s", "(" + classes + " classes) ") + jars + "\n")); | ||
|
||
| return builder.toString(); | ||
| } | ||
|
|
||
| /** | ||
| * 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); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| /* | ||
| * (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.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.jar.JarEntry; | ||
| import java.util.jar.JarFile; | ||
| import org.gradle.api.artifacts.Configuration; | ||
| import org.gradle.api.artifacts.ModuleVersionIdentifier; | ||
| import org.gradle.api.artifacts.ResolvedArtifact; | ||
| import org.slf4j.Logger; | ||
|
|
||
| public final class ClassUniquenessAnalyzer { | ||
|
|
||
| private final Map<String, Set<ModuleVersionIdentifier>> classToJarsMap = new HashMap<>(); | ||
| private final Map<Set<ModuleVersionIdentifier>, Set<String>> jarsToClasses = new HashMap<>(); | ||
| private final Logger log; | ||
|
|
||
| public ClassUniquenessAnalyzer(Logger log) { | ||
| this.log = log; | ||
| } | ||
|
|
||
| public void analyzeConfiguration(Configuration configuration) { | ||
| Instant before = Instant.now(); | ||
|
|
||
| Set<ResolvedArtifact> dependencies = configuration | ||
| .getResolvedConfiguration() | ||
| .getResolvedArtifacts(); | ||
|
|
||
| Map<String, Set<ModuleVersionIdentifier>> tempClassToJarMap = new HashMap<>(); | ||
|
||
| dependencies.stream().forEach(resolvedArtifact -> { | ||
| File file = resolvedArtifact.getFile(); | ||
| if (!file.exists()) { | ||
|
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. probably wanna filter out non-jars too to avoid spurious errors in line 92
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. TBH I'd kinda like to just get an MVP released and get a few repos to pick it up and see what we encounter! The |
||
| log.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; | ||
| } | ||
|
|
||
| multiMapPut(tempClassToJarMap, | ||
| jarEntry.getName().replaceAll("/", ".").replaceAll(".class", ""), | ||
| resolvedArtifact.getModuleVersion().getId()); | ||
| } | ||
| } catch (Exception e) { | ||
| log.error("Failed to read JarFile {}", resolvedArtifact, e); | ||
|
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. the resolvedArtifact might not be a jar -- we should probs filter those out first, a la https://github.com/nebula-plugins/gradle-lint-plugin/blob/master/src/main/groovy/com/netflix/nebula/lint/rule/dependency/DependencyService.groovy#L92-L94 |
||
| throw new RuntimeException(e); | ||
| } | ||
| }); | ||
|
|
||
| tempClassToJarMap.entrySet().stream() | ||
| .filter(entry -> entry.getValue().size() > 1) | ||
| .forEach(entry -> { | ||
| // add to the top level map | ||
| entry.getValue().forEach(value -> multiMapPut(classToJarsMap, entry.getKey(), value)); | ||
|
||
|
|
||
| // add to the opposite direction index | ||
| multiMapPut(jarsToClasses, entry.getValue(), entry.getKey()); | ||
| }); | ||
|
|
||
| Instant after = Instant.now(); | ||
| log.info("Checked {} classes from {} dependencies for uniqueness ({}ms)", | ||
| tempClassToJarMap.size(), dependencies.size(), Duration.between(before, after).toMillis()); | ||
| } | ||
|
|
||
| public Collection<Set<ModuleVersionIdentifier>> getProblemJars() { | ||
| return classToJarsMap.values(); | ||
| } | ||
|
|
||
| public Map<Set<ModuleVersionIdentifier>, Set<String>> jarsToClasses() { | ||
| return jarsToClasses; | ||
| } | ||
|
|
||
| public Set<String> getDuplicateClassesInProblemJars(Set<ModuleVersionIdentifier> problemJars) { | ||
| return jarsToClasses.get(problemJars); | ||
| } | ||
|
|
||
| private static <K, V> void multiMapPut(Map<K, Set<V>> map, K key, V value) { | ||
| map.compute(key, (unused, collection) -> { | ||
| Set<V> newCollection = collection != null ? collection : new HashSet<>(); | ||
| 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 |
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