diff --git a/src/main/java/org/openrewrite/java/dependencies/RemoveRedundantDependencies.java b/src/main/java/org/openrewrite/java/dependencies/RemoveRedundantDependencies.java new file mode 100644 index 0000000..c44d7a9 --- /dev/null +++ b/src/main/java/org/openrewrite/java/dependencies/RemoveRedundantDependencies.java @@ -0,0 +1,337 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * 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 + *

+ * https://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 org.openrewrite.java.dependencies; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.jspecify.annotations.Nullable; +import org.openrewrite.*; +import org.openrewrite.gradle.marker.GradleDependencyConfiguration; +import org.openrewrite.gradle.marker.GradleProject; +import org.openrewrite.internal.ListUtils; +import org.openrewrite.internal.StringUtils; +import org.openrewrite.maven.MavenDownloadingException; +import org.openrewrite.maven.MavenDownloadingExceptions; +import org.openrewrite.maven.internal.MavenPomDownloader; +import org.openrewrite.maven.tree.*; + +import java.util.*; + +import static java.util.Collections.*; + +@EqualsAndHashCode(callSuper = false) +@Value +public class RemoveRedundantDependencies extends ScanningRecipe { + + @Option(displayName = "Group ID", + description = "The first part of a dependency coordinate `com.google.guava:guava:VERSION` of the parent dependency. This can be a glob expression.", + example = "com.fasterxml.jackson.core") + String groupId; + + @Option(displayName = "Artifact ID", + description = "The second part of a dependency coordinate `com.google.guava:guava:VERSION` of the parent dependency. This can be a glob expression.", + example = "jackson-databind") + String artifactId; + + @Override + public String getDisplayName() { + return "Remove redundant explicit dependencies"; + } + + @Override + public String getDescription() { + return "Remove explicit dependencies that are already provided transitively by a specified dependency. " + + "This recipe downloads and resolves the parent dependency's POM to determine its true transitive " + + "dependencies, allowing it to detect redundancies even when both dependencies are explicitly declared."; + } + + @Value + public static class Accumulator { + // Map from project identifier -> scope/configuration -> Set of transitive GAVs + Map>> transitivesByProjectAndScope; + } + + @Override + public Accumulator getInitialValue(ExecutionContext ctx) { + return new Accumulator(new HashMap<>()); + } + + @Override + public TreeVisitor getScanner(Accumulator acc) { + return new TreeVisitor() { + @Override + public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + if (tree == null) { + return null; + } + + tree.getMarkers().findFirst(GradleProject.class).ifPresent(gradle -> { + String projectId = gradle.getGroup() + ":" + gradle.getName(); + MavenPomDownloader downloader = new MavenPomDownloader(ctx); + + for (GradleDependencyConfiguration conf : gradle.getConfigurations()) { + for (ResolvedDependency dep : conf.getResolved()) { + if (dep.isDirect() && + StringUtils.matchesGlob(dep.getGroupId(), groupId) && + StringUtils.matchesGlob(dep.getArtifactId(), artifactId)) { + // This is a matching parent dependency, resolve its transitives independently + Set transitives = acc.transitivesByProjectAndScope + .computeIfAbsent(projectId, k -> new HashMap<>()) + .computeIfAbsent(conf.getName(), k -> new HashSet<>()); + resolveTransitivesFromPom( + dep.getGav(), + dep.getEffectiveExclusions(), + gradle.getMavenRepositories(), + downloader, + ctx, + transitives); + } + } + } + }); + + tree.getMarkers().findFirst(MavenResolutionResult.class).ifPresent(maven -> { + String projectId = maven.getPom().getGroupId() + ":" + maven.getPom().getArtifactId(); + MavenPomDownloader downloader = new MavenPomDownloader(ctx); + + for (Map.Entry> entry : maven.getDependencies().entrySet()) { + Scope depScope = entry.getKey(); + for (ResolvedDependency dep : entry.getValue()) { + if (dep.isDirect() && + StringUtils.matchesGlob(dep.getGroupId(), groupId) && + StringUtils.matchesGlob(dep.getArtifactId(), artifactId)) { + // This is a matching parent dependency, resolve its transitives independently + Set transitives = acc.transitivesByProjectAndScope + .computeIfAbsent(projectId, k -> new HashMap<>()) + .computeIfAbsent(depScope.name().toLowerCase(), k -> new HashSet<>()); + resolveTransitivesFromPom( + dep.getGav(), + dep.getEffectiveExclusions(), + maven.getPom().getRepositories(), + downloader, + ctx, + transitives); + } + } + } + }); + + return tree; + } + + private void resolveTransitivesFromPom( + ResolvedGroupArtifactVersion gav, + List effectiveExclusions, + List repositories, + MavenPomDownloader downloader, + ExecutionContext ctx, + Set transitives) { + try { + // Ensure we have Maven Central in the repositories + List effectiveRepos = new ArrayList<>(repositories); + if (effectiveRepos.stream().noneMatch(r -> r.getUri().contains("repo.maven.apache.org") || + r.getUri().contains("repo1.maven.org"))) { + effectiveRepos.add(MavenRepository.MAVEN_CENTRAL); + } + + // Get the resolved dependencies for compile scope (which includes most transitives) + Pom pom = downloader.download(gav.asGroupArtifactVersion(), null, null, effectiveRepos); + ResolvedPom resolvedPom = pom.resolve(emptyList(), downloader, effectiveRepos, ctx); + ResolvedPom patchedPom = applyExclusions(resolvedPom, effectiveExclusions); + List resolved = patchedPom.resolveDependencies(Scope.Compile, downloader, ctx); + + // Collect all dependencies (both direct and transitive of the parent) + for (ResolvedDependency dep : resolved) { + collectAllDependencies(dep, transitives); + } + } catch (MavenDownloadingException | MavenDownloadingExceptions e) { + // If we can't download/resolve the POM, fall back to not detecting redundancies + // This is a best-effort approach + } + } + + private ResolvedPom applyExclusions(ResolvedPom resolvedPom, List effectiveExclusions) { + ResolvedPom patchedPom = resolvedPom.withRequested(resolvedPom.getRequested().withDependencies( + ListUtils.filter(resolvedPom.getRequested().getDependencies(), d -> effectiveExclusions.stream() + .noneMatch(e -> e.getGroupId().equals(d.getGroupId()) && e.getArtifactId().equals(d.getArtifactId()))))); + patchedPom.getRequestedDependencies().removeIf(d -> effectiveExclusions.stream() + .anyMatch(e -> e.getGroupId().equals(d.getGroupId()) && e.getArtifactId().equals(d.getArtifactId()))); + return patchedPom; + } + + private void collectAllDependencies(ResolvedDependency dep, Set transitives) { + if (transitives.add(dep.getGav())) { + for (ResolvedDependency transitive : dep.getDependencies()) { + collectAllDependencies(transitive, transitives); + } + } + } + }; + } + + @Override + public TreeVisitor getVisitor(Accumulator acc) { + return new TreeVisitor() { + @Override + public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { + if (!(tree instanceof SourceFile)) { + return tree; + } + + SourceFile sf = (SourceFile) tree; + Tree result = sf; + + // Handle Gradle + Optional gradleOpt = sf.getMarkers().findFirst(GradleProject.class); + if (gradleOpt.isPresent()) { + GradleProject gradle = gradleOpt.get(); + String projectId = gradle.getGroup() + ":" + gradle.getName(); + Map> scopeToTransitives = + acc.transitivesByProjectAndScope.getOrDefault(projectId, emptyMap()); + + for (GradleDependencyConfiguration conf : gradle.getConfigurations()) { + Set transitives = getCompatibleTransitives( + scopeToTransitives, conf.getName(), true); + if (transitives.isEmpty()) { + continue; + } + + for (ResolvedDependency dep : conf.getResolved()) { + if (dep.isDirect() && + doesNotMatchArguments(dep) && + isInTransitives(dep, transitives)) { + // This direct dependency is transitively provided, remove it + // Don't specify configuration - Gradle's resolved config names differ from declaration names + result = new RemoveDependency( + dep.getGroupId(), dep.getArtifactId(), null, null, null) + .getVisitor().visit(result, ctx); + } + } + } + return result; + } + + // Handle Maven + Optional mavenOpt = sf.getMarkers().findFirst(MavenResolutionResult.class); + if (mavenOpt.isPresent()) { + MavenResolutionResult maven = mavenOpt.get(); + String projectId = maven.getPom().getGroupId() + ":" + maven.getPom().getArtifactId(); + Map> scopeToTransitives = + acc.transitivesByProjectAndScope.getOrDefault(projectId, emptyMap()); + + for (Map.Entry> entry : maven.getDependencies().entrySet()) { + String scope = entry.getKey().name().toLowerCase(); + Set transitives = getCompatibleTransitives( + scopeToTransitives, scope, false); + if (transitives.isEmpty()) { + continue; + } + + for (ResolvedDependency dep : entry.getValue()) { + if (dep.isDirect() && + doesNotMatchArguments(dep) && + isInTransitives(dep, transitives)) { + // This direct dependency is transitively provided, remove it + result = new RemoveDependency( + dep.getGroupId(), dep.getArtifactId(), null, null, scope) + .getVisitor().visit(result, ctx); + } + } + } + return result; + } + + return tree; + } + + private boolean doesNotMatchArguments(ResolvedDependency dep) { + return !StringUtils.matchesGlob(dep.getGroupId(), groupId) || + !StringUtils.matchesGlob(dep.getArtifactId(), artifactId); + } + + private boolean isInTransitives(ResolvedDependency dep, Set transitives) { + // Check if this dependency's GAV matches any transitive + // We match on groupId:artifactId:version exactly + for (ResolvedGroupArtifactVersion transitive : transitives) { + if (dep.getGroupId().equals(transitive.getGroupId()) && + dep.getArtifactId().equals(transitive.getArtifactId()) && + dep.getVersion().equals(transitive.getVersion())) { + return true; + } + } + return false; + } + + /** + * Get transitives from this scope/configuration and any broader ones. + */ + private Set getCompatibleTransitives( + Map> scopeToTransitives, + String targetScope, + boolean isGradle) { + + Set result = new HashSet<>(); + + // Always include transitives from the same scope + Set sameScope = scopeToTransitives.get(targetScope); + if (sameScope != null) { + result.addAll(sameScope); + } + + // Include transitives from broader scopes + List broaderScopes = isGradle ? + getBroaderGradleScopes(targetScope) : + getBroaderMavenScopes(targetScope); + for (String broader : broaderScopes) { + Set broaderTransitives = scopeToTransitives.get(broader); + if (broaderTransitives != null) { + result.addAll(broaderTransitives); + } + } + + return result; + } + + private List getBroaderGradleScopes(String scope) { + switch (scope.toLowerCase()) { + case "runtimeonly": + case "runtimeclasspath": + return Arrays.asList("implementation", "api"); + case "implementation": + return singletonList("api"); + case "testimplementation": + case "testruntimeonly": + return Arrays.asList("implementation", "api", "testImplementation"); + default: + return emptyList(); + } + } + + private List getBroaderMavenScopes(String scope) { + switch (scope.toLowerCase()) { + case "runtime": + return singletonList("compile"); + case "provided": + return Arrays.asList("compile", "runtime"); + case "test": + return Arrays.asList("compile", "runtime", "provided"); + default: + return emptyList(); + } + } + }; + } +} diff --git a/src/test/java/org/openrewrite/java/dependencies/RemoveRedundantDependenciesTest.java b/src/test/java/org/openrewrite/java/dependencies/RemoveRedundantDependenciesTest.java new file mode 100644 index 0000000..ef8d001 --- /dev/null +++ b/src/test/java/org/openrewrite/java/dependencies/RemoveRedundantDependenciesTest.java @@ -0,0 +1,350 @@ +/* + * Copyright 2025 the original author or authors. + *

+ * 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 + *

+ * https://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 org.openrewrite.java.dependencies; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.gradle.Assertions.buildGradle; +import static org.openrewrite.gradle.toolingapi.Assertions.withToolingApi; +import static org.openrewrite.java.Assertions.mavenProject; +import static org.openrewrite.maven.Assertions.pomXml; + +class RemoveRedundantDependenciesTest implements RewriteTest { + @DocumentExample + @Test + void removeRedundantMavenDependency() { + rewriteRun( + spec -> spec.recipe(new RemoveRedundantDependencies( + "com.fasterxml.jackson.core", "jackson-databind")), + mavenProject("my-app", + //language=xml + pomXml( + """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + + com.fasterxml.jackson.core + jackson-databind + 2.17.0 + + + com.fasterxml.jackson.core + jackson-core + 2.17.0 + + + + """, + """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + + com.fasterxml.jackson.core + jackson-databind + 2.17.0 + + + + """ + ) + ) + ); + } + + @Test + void removeRedundantMavenDependencyInTestScope() { + rewriteRun( + spec -> spec.recipe(new RemoveRedundantDependencies( + "org.junit.jupiter", "junit-jupiter-engine")), + mavenProject("my-app", + //language=xml + pomXml( + """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + + org.junit.jupiter + junit-jupiter-engine + 6.0.1 + test + + + org.junit.jupiter + junit-jupiter-api + 6.0.1 + test + + + + """, + """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + + org.junit.jupiter + junit-jupiter-engine + 6.0.1 + test + + + + """ + ) + ) + ); + } + + @Test + void removeMultipleRedundantDependencies() { + rewriteRun( + spec -> spec.recipe(new RemoveRedundantDependencies( + "com.fasterxml.jackson.core", "jackson-databind")), + mavenProject("my-app", + //language=xml + pomXml( + """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + + com.fasterxml.jackson.core + jackson-databind + 2.17.0 + + + com.fasterxml.jackson.core + jackson-core + 2.17.0 + + + com.fasterxml.jackson.core + jackson-annotations + 2.17.0 + + + + """, + """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + + com.fasterxml.jackson.core + jackson-databind + 2.17.0 + + + + """ + ) + ) + ); + } + + @Test + void doNotRemoveWhenVersionsDiffer() { + rewriteRun( + spec -> spec.recipe(new RemoveRedundantDependencies( + "com.fasterxml.jackson.core", "jackson-databind")), + mavenProject("my-app", + //language=xml + pomXml( + """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + + com.fasterxml.jackson.core + jackson-databind + 2.17.0 + + + com.fasterxml.jackson.core + jackson-core + 2.16.0 + + + + """ + ) + ) + ); + } + + @Test + void doNoRemoveWhenExcluded() { + rewriteRun( + spec -> spec.recipe(new RemoveRedundantDependencies( + "com.fasterxml.jackson.core", "jackson-databind")), + mavenProject("my-app", + //language=xml + pomXml( + """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + + com.fasterxml.jackson.core + jackson-databind + 2.17.0 + + + com.fasterxml.jackson.core + jackson-core + + + + + com.fasterxml.jackson.core + jackson-core + 2.17.0 + + + + """ + ) + ) + ); + } + + @Test + void doNotRemoveDirectDependencyItself() { + rewriteRun( + spec -> spec.recipe(new RemoveRedundantDependencies( + "com.fasterxml.jackson.core", "jackson-databind")), + mavenProject("my-app", + //language=xml + pomXml( + """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + + com.fasterxml.jackson.core + jackson-databind + 2.17.0 + + + + """ + ) + ) + ); + } + + @Test + void noMatchingParentDependency() { + rewriteRun( + spec -> spec.recipe(new RemoveRedundantDependencies( + "org.nonexistent", "nonexistent")), + mavenProject("my-app", + //language=xml + pomXml( + """ + + 4.0.0 + com.mycompany.app + my-app + 1 + + + com.fasterxml.jackson.core + jackson-databind + 2.17.0 + + + com.fasterxml.jackson.core + jackson-core + 2.17.0 + + + + """ + ) + ) + ); + } + + @Test + void removeRedundantGradleDependency() { + rewriteRun( + spec -> spec.beforeRecipe(withToolingApi()) + .recipe(new RemoveRedundantDependencies( + "com.fasterxml.jackson.core", "jackson-databind")), + mavenProject("my-app", + //language=groovy + buildGradle( + """ + plugins { + id 'java-library' + } + repositories { + mavenCentral() + } + dependencies { + implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.0' + implementation 'com.fasterxml.jackson.core:jackson-core:2.17.0' + } + """, + """ + plugins { + id 'java-library' + } + repositories { + mavenCentral() + } + dependencies { + implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.0' + } + """ + ) + ) + ); + } +}