All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.palantir.baseline.plugins.BaselineExactDependencies Maven / Gradle / Ivy

There is a newer version: 5.68.0
Show newest version
/*
 * (c) Copyright 2019 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.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableMap.Builder;
import com.google.common.collect.ImmutableSet;
import com.palantir.baseline.tasks.CheckImplicitDependenciesParentTask;
import com.palantir.baseline.tasks.CheckImplicitDependenciesTask;
import com.palantir.baseline.tasks.CheckUnusedDependenciesParentTask;
import com.palantir.baseline.tasks.CheckUnusedDependenciesTask;
import java.io.File;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.annotation.concurrent.ThreadSafe;
import org.apache.commons.lang3.StringUtils;
import org.apache.maven.shared.dependency.analyzer.ClassAnalyzer;
import org.apache.maven.shared.dependency.analyzer.DefaultClassAnalyzer;
import org.apache.maven.shared.dependency.analyzer.DependencyAnalyzer;
import org.apache.maven.shared.dependency.analyzer.asm.ASMDependencyAnalyzer;
import org.gradle.api.NamedDomainObjectProvider;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.artifacts.Configuration;
import org.gradle.api.artifacts.ExcludeRule;
import org.gradle.api.artifacts.ModuleVersionIdentifier;
import org.gradle.api.artifacts.ResolvedArtifact;
import org.gradle.api.artifacts.ResolvedDependency;
import org.gradle.api.artifacts.component.ComponentIdentifier;
import org.gradle.api.artifacts.component.ProjectComponentIdentifier;
import org.gradle.api.attributes.LibraryElements;
import org.gradle.api.attributes.Usage;
import org.gradle.api.plugins.JavaPluginConvention;
import org.gradle.api.tasks.SourceSet;
import org.gradle.api.tasks.TaskProvider;
import org.gradle.util.GUtil;

/** Validates that java projects declare exactly the dependencies they rely on, no more and no less. */
public final class BaselineExactDependencies implements Plugin {

    private static final ClassAnalyzer JAR_ANALYZER = new DefaultClassAnalyzer();
    private static final DependencyAnalyzer CLASS_FILE_ANALYZER = new ASMDependencyAnalyzer();

    // All applications of this plugin share a single static 'Indexes' instance, because the classes
    // contained in a particular jar are immutable.
    public static final Indexes INDEXES = new Indexes();
    public static final ImmutableSet VALID_ARTIFACT_EXTENSIONS = ImmutableSet.of("jar", "");

    @Override
    public void apply(Project project) {
        project.getPluginManager().withPlugin("java", plugin -> {
            TaskProvider checkUnusedDependencies =
                    project.getTasks().register("checkUnusedDependencies", CheckUnusedDependenciesParentTask.class);
            TaskProvider checkImplicitDependencies =
                    project.getTasks().register("checkImplicitDependencies", CheckImplicitDependenciesParentTask.class);

            project.getConvention()
                    .getPlugin(JavaPluginConvention.class)
                    .getSourceSets()
                    .configureEach(sourceSet ->
                            configureSourceSet(project, sourceSet, checkUnusedDependencies, checkImplicitDependencies));
        });
    }

    private static void configureSourceSet(
            Project project,
            SourceSet sourceSet,
            TaskProvider checkUnusedDependencies,
            TaskProvider checkImplicitDependencies) {
        NamedDomainObjectProvider implementation =
                project.getConfigurations().named(sourceSet.getImplementationConfigurationName());
        NamedDomainObjectProvider compileClasspath =
                project.getConfigurations().named(sourceSet.getCompileClasspathConfigurationName());

        NamedDomainObjectProvider explicitCompile = project.getConfigurations()
                .register("baseline-exact-dependencies-" + sourceSet.getName(), conf -> {
                    conf.setDescription(String.format(
                            "Tracks the explicit (not inherited) dependencies added to either %s "
                                    + "or compile (deprecated)",
                            sourceSet.getImplementationConfigurationName()));
                    conf.setVisible(false);
                    conf.setCanBeConsumed(false);

                    conf.attributes(attributes -> {
                        // This ensures we resolve 'compile' variants rather than 'runtime'
                        // This is the same attribute that's being set on compileClasspath
                        attributes.attribute(
                                Usage.USAGE_ATTRIBUTE, project.getObjects().named(Usage.class, Usage.JAVA_API));
                        // Ensure we resolve the classes directory for local projects where possible, rather than the
                        // 'jar' file.
                        attributes.attribute(
                                LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE,
                                project.getObjects().named(LibraryElements.class, LibraryElements.CLASSES));
                    });

                    conf.withDependencies(deps -> {
                        // Pick up GCV locks. We're making an internal assumption that this configuration exists,
                        // but we can rely on this since we control GCV.
                        // Alternatively, we could tell GCV to lock this configuration, at the cost of a slightly more
                        // expensive 'unifiedClasspath' resolution during lock computation.
                        if (project.getRootProject().getPluginManager().hasPlugin("com.palantir.versions-lock")) {
                            conf.extendsFrom(project.getConfigurations().getByName("lockConstraints"));
                        }
                        // Inherit the excludes from compileClasspath too (that get aggregated from all its
                        // super-configurations).
                        compileClasspath.get().getExcludeRules().forEach(rule -> conf.exclude(excludeRuleAsMap(rule)));
                    });

                    // Since we are copying configurations before resolving 'explicitCompile', make double sure that
                    // it's not
                    // being resolved (or dependencies realized via `.getIncoming().getDependencies()`) too early.
                    AtomicBoolean projectsEvaluated = new AtomicBoolean();
                    project.getGradle().projectsEvaluated(g -> projectsEvaluated.set(true));
                    conf.getIncoming()
                            .beforeResolve(ir -> Preconditions.checkState(
                                    projectsEvaluated.get()
                                            || (project.getGradle()
                                                            .getStartParameter()
                                                            .isConfigureOnDemand()
                                                    && project.getState().getExecuted()),
                                    "Tried to resolve %s too early.",
                                    conf));
                });

        // Figure out what our compile dependencies are while ignoring dependencies we've inherited from other source
        // sets. For example, if we are `test`, some of our configurations extend from the `main` source set:
        // testImplementation     extendsFrom(implementation)
        //  \-- testCompile       extendsFrom(compile)
        // We therefore want to look at only the dependencies _directly_ declared in the implementation and compile
        // configurations (belonging to our source set)
        project.afterEvaluate(p -> {
            Configuration implCopy = implementation.get().copy();
            // Without these, explicitCompile will successfully resolve 0 files and you'll waste 1 hour trying
            // to figure out why.
            project.getConfigurations().add(implCopy);

            explicitCompile.get().extendsFrom(implCopy);

            Optional maybeCompile =
                    Optional.ofNullable(project.getConfigurations().findByName(getCompileConfigurationName(sourceSet)));

            // For Gradle 6 and below, the compile configuration might still be used.
            maybeCompile.ifPresent(compile -> {
                Configuration compileCopy = compile.copy();
                // Ensure it's not resolvable, otherwise plugins that resolve all configurations might have
                // a bad time resolving this with GCV, if you have direct dependencies without corresponding entries in
                // versions.props, but instead rely on getting a version for them from the lock file.
                compileCopy.setCanBeResolved(false);
                compileCopy.setCanBeConsumed(false);

                project.getConfigurations().add(compileCopy);

                explicitCompile.get().extendsFrom(compileCopy);
            });
        });

        TaskProvider sourceSetUnusedDependencies = project.getTasks()
                .register(
                        checkUnusedDependenciesNameForSourceSet(sourceSet), CheckUnusedDependenciesTask.class, task -> {
                            task.dependsOn(sourceSet.getClassesTaskName());
                            task.setSourceClasses(sourceSet.getOutput().getClassesDirs());
                            task.getDependenciesConfigurations().add(explicitCompile);

                            // ignore intra-project dependencies, which are typically added automatically for things
                            // like test fixtures
                            task.ignore(project.provider(() ->
                                    Set.of(ignoreCoordinate(project.getGroup().toString(), project.getName()))));

                            // this is liberally applied to ease the Java8 -> 11 transition
                            task.ignore("javax.annotation", "javax.annotation-api");

                            // this is typically used instead of junit-jupiter-api to simplify configuration
                            task.ignore("org.junit.jupiter", "junit-jupiter");

                            // pick up ignores configured globally on the parent task
                            task.ignore(checkUnusedDependencies.get().getIgnore());
                        });
        checkUnusedDependencies.configure(task -> task.dependsOn(sourceSetUnusedDependencies));
        TaskProvider sourceSetCheckImplicitDependencies = project.getTasks()
                .register(
                        "checkImplicitDependencies" + StringUtils.capitalize(sourceSet.getName()),
                        CheckImplicitDependenciesTask.class,
                        task -> {
                            task.dependsOn(sourceSet.getClassesTaskName());
                            task.setSourceClasses(sourceSet.getOutput().getClassesDirs());
                            task.getDependenciesConfigurations().add(compileClasspath);
                            task.suggestionConfigurationName(sourceSet.getImplementationConfigurationName());

                            task.ignore("org.slf4j", "slf4j-api");

                            // pick up ignores configured globally on the parent task
                            task.ignore(checkImplicitDependencies.get().getIgnore());
                        });
        checkImplicitDependencies.configure(task -> task.dependsOn(sourceSetCheckImplicitDependencies));
    }

    static String checkUnusedDependenciesNameForSourceSet(SourceSet sourceSet) {
        return "checkUnusedDependencies" + StringUtils.capitalize(sourceSet.getName());
    }

    /**
     * The {@code SourceSet#getCompileConfigurationName()} method got removed in Gradle 7. Because we want to stay
     * compatible with Gradle 6 but can't compile this method, we reimplement it temporarily.
     * TODO(fwindheuser): Remove after dropping support for Gradle 6.
     */
    private static String getCompileConfigurationName(SourceSet sourceSet) {
        String baseName = sourceSet.getName().equals(SourceSet.MAIN_SOURCE_SET_NAME)
                ? ""
                : GUtil.toCamelCase(sourceSet.getName());
        return StringUtils.uncapitalize(baseName + StringUtils.capitalize("compile"));
    }

    private static Map excludeRuleAsMap(ExcludeRule rule) {
        // Both 'ExcludeRule#getGroup' and 'ExcludeRule#getModule' can return null.
        Builder excludeRule = ImmutableMap.builder();
        if (rule.getGroup() != null) {
            excludeRule.put("group", rule.getGroup());
        }
        if (rule.getModule() != null) {
            excludeRule.put("module", rule.getModule());
        }
        return excludeRule.build();
    }

    /** Given a {@code com/palantir/product/Foo.class} file, what other classes does it import/reference. */
    public static Stream referencedClasses(File classFile) {
        try {
            return BaselineExactDependencies.CLASS_FILE_ANALYZER
                    .analyze(classFile.toURI().toURL())
                    .stream();
        } catch (IOException e) {
            throw new RuntimeException("Unable to analyze " + classFile, e);
        }
    }

    public static String asString(ResolvedArtifact artifact) {
        ModuleVersionIdentifier moduleVersionId = artifact.getModuleVersion().getId();
        StringBuilder builder = new StringBuilder()
                .append(moduleVersionId.getGroup())
                .append(":")
                .append(moduleVersionId.getName());
        if (artifact.getClassifier() != null) {
            builder.append("::").append(artifact.getClassifier());
        }
        return builder.toString();
    }

    public static String asDependencyStringWithName(ResolvedArtifact artifact) {
        return asDependencyString(artifact, true);
    }

    public static String asDependencyStringWithoutName(ResolvedArtifact artifact) {
        return asDependencyString(artifact, false);
    }

    private static String asDependencyString(ResolvedArtifact artifact, boolean withName) {
        ComponentIdentifier componentId = artifact.getId().getComponentIdentifier();
        if (componentId instanceof ProjectComponentIdentifier) {
            ProjectComponentIdentifier projectComponentId = (ProjectComponentIdentifier) componentId;
            StringBuilder builder = new StringBuilder()
                    .append("project('")
                    .append(projectComponentId.getProjectPath())
                    .append("')");
            if (withName) {
                builder.append(" <-- ").append(artifact.getName());
            }
            return builder.toString();
        }

        return asString(artifact);
    }

    @ThreadSafe
    public static final class Indexes {
        private final Map> classToDependency = new ConcurrentHashMap<>();
        private final Map> classesFromArtifact = new ConcurrentHashMap<>();
        private final Map artifactsFromDependency = new ConcurrentHashMap<>();

        public void populateIndexes(Set declaredDependencies) {
            Set allArtifacts = declaredDependencies.stream()
                    .flatMap(dependency -> dependency.getAllModuleArtifacts().stream())
                    .filter(dependency -> VALID_ARTIFACT_EXTENSIONS.contains(dependency.getExtension()))
                    .collect(Collectors.toSet());

            allArtifacts.forEach(artifact -> {
                try {
                    File jar = artifact.getFile();
                    Set classesInArtifact =
                            JAR_ANALYZER.analyze(jar.toURI().toURL());
                    classesFromArtifact.put(artifact, classesInArtifact);
                    classesInArtifact.forEach(clazz -> classToDependency
                            .computeIfAbsent(clazz, _ignored -> ConcurrentHashMap.newKeySet())
                            .add(artifact));
                } catch (IOException e) {
                    throw new RuntimeException("Unable to analyze artifact", e);
                }
            });

            declaredDependencies.forEach(dependency -> dependency
                    .getModuleArtifacts()
                    .forEach(artifact -> artifactsFromDependency.put(artifact, dependency)));
        }

        /** Given a class, what dependency brought it in. */
        public Stream classToArtifacts(String clazz) {
            return classToDependency.getOrDefault(clazz, ImmutableSet.of()).stream();
        }

        /** Given an artifact, what classes does it contain. */
        public Stream classesFromArtifact(ResolvedArtifact resolvedArtifact) {
            return Preconditions.checkNotNull(
                    classesFromArtifact.get(resolvedArtifact), "Unable to find resolved artifact")
                    .stream();
        }

        public ResolvedDependency artifactsFromDependency(ResolvedArtifact resolvedArtifact) {
            return Preconditions.checkNotNull(
                    artifactsFromDependency.get(resolvedArtifact), "Unable to find resolved artifact");
        }
    }

    public static String ignoreCoordinate(String group, String name) {
        return group + ":" + name;
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy