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, ExecutionContext> 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, ExecutionContext> 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'
+ }
+ """
+ )
+ )
+ );
+ }
+}