2929import org .openrewrite .maven .tree .*;
3030
3131import java .util .*;
32+ import java .util .function .Consumer ;
3233
3334import static java .util .Collections .*;
3435
@@ -52,14 +53,14 @@ public class RemoveRedundantDependencies extends ScanningRecipe<RemoveRedundantD
5253 "This recipe downloads and resolves the parent dependency's POM to determine its true transitive " +
5354 "dependencies, allowing it to detect redundancies even when both dependencies are explicitly declared. " +
5455 "A direct dependency is only removed when the transitive one provides it at the exact same scope and " +
55- "with the same effective exclusions, so that removing it does not change the effective classpath." ;
56+ "with equivalent exclusions, so that removing it does not change the effective classpath." ;
5657
5758 @ Value
5859 public static class Accumulator {
5960 // Map from project identifier -> scope/configuration -> Set of transitive dependencies
6061 Map <String , Map <String , Set <TransitiveDependency >>> transitivesByProjectAndScope ;
61- // Cache of each coordinate's own clean dependency closure, shared across all source files in the run
62- Map <ResolvedGroupArtifactVersion , Set <GroupArtifact >> closureCache ;
62+ // Run-scoped: every closure miss costs a POM download and resolve
63+ Map <GroupArtifactVersion , Set <GroupArtifact >> closureCache ;
6364 }
6465
6566 @ Value
@@ -147,18 +148,17 @@ private void resolveTransitivesFromPom(
147148 MavenPomDownloader downloader ,
148149 ExecutionContext ctx ,
149150 Set <TransitiveDependency > transitives ) {
150- List <MavenRepository > effectiveRepos = withMavenCentral (repositories );
151151 try {
152152 // Get the resolved dependencies for compile scope (which includes most transitives)
153- Pom pom = downloader . download ( gav . asGroupArtifactVersion (), null , null , effectiveRepos );
154- ResolvedPom resolvedPom = pom . resolve ( emptyList ( ), downloader , effectiveRepos , ctx );
153+ ResolvedPom resolvedPom = resolvePom (
154+ gav . asGroupArtifactVersion ( ), withMavenCentral ( repositories ), downloader , ctx );
155155 ResolvedPom patchedPom = applyExclusions (resolvedPom , effectiveExclusions );
156- List <ResolvedDependency > resolved = patchedPom .resolveDependencies (Scope .Compile , downloader , ctx );
157156
158157 // Collect all dependencies (both direct and transitive of the parent)
159158 Set <ResolvedGroupArtifactVersion > visited = new HashSet <>();
160- for (ResolvedDependency dep : resolved ) {
161- collectAllDependencies (dep , transitives , visited , effectiveRepos , downloader , ctx );
159+ for (ResolvedDependency dep : patchedPom .resolveDependencies (Scope .Compile , downloader , ctx )) {
160+ walkDependencies (dep , visited , d ->
161+ transitives .add (new TransitiveDependency (d .getGav (), declaredExclusions (d ))));
162162 }
163163 } catch (MavenDownloadingException | MavenDownloadingExceptions e ) {
164164 // If we can't download/resolve the POM, fall back to not detecting redundancies
@@ -174,62 +174,32 @@ private ResolvedPom applyExclusions(ResolvedPom resolvedPom, List<GroupArtifact>
174174 .anyMatch (e -> e .getGroupId ().equals (d .getGroupId ()) && e .getArtifactId ().equals (d .getArtifactId ())));
175175 return patchedPom ;
176176 }
177-
178- private void collectAllDependencies (ResolvedDependency dep , Set <TransitiveDependency > transitives ,
179- Set <ResolvedGroupArtifactVersion > visited , List <MavenRepository > repositories ,
180- MavenPomDownloader downloader , ExecutionContext ctx ) {
181- if (visited .add (dep .getGav ())) {
182- transitives .add (new TransitiveDependency (dep .getGav (),
183- relevantExclusions (dep , repositories , downloader , ctx , acc .closureCache )));
184- for (ResolvedDependency transitive : dep .getDependencies ()) {
185- collectAllDependencies (transitive , transitives , visited , repositories , downloader , ctx );
186- }
187- }
188- }
189177 };
190178 }
191179
192- // Compares a coordinate's declared (requested) exclusions rather than its effective ones: within a large
193- // tree the effective set is corrupted by dependency mediation (an artifact excluded here may already have
194- // been pruned by a shared node elsewhere, so it silently drops out of getEffectiveExclusions). The declared
195- // exclusions are then intersected with the coordinate's own clean closure, dropping no-op exclusions that
196- // target artifacts the coordinate could never bring on its own. Comparing this set on the direct and the
197- // transitively-provided declaration tells us whether removing the direct one changes the effective classpath.
198- private static Set <GroupArtifact > relevantExclusions (ResolvedDependency dep , List <MavenRepository > repositories ,
199- MavenPomDownloader downloader , ExecutionContext ctx ,
200- Map <ResolvedGroupArtifactVersion , Set <GroupArtifact >> closureCache ) {
201- List <GroupArtifact > requested = dep .getRequested () == null ? null : dep .getRequested ().getExclusions ();
202- if (requested == null || requested .isEmpty ()) {
203- return emptySet ();
204- }
205- Set <GroupArtifact > exclusions = new HashSet <>(requested );
206- exclusions .retainAll (dependencyClosure (dep .getGav (), repositories , downloader , ctx , closureCache ));
207- return exclusions ;
180+ // Declared (requested) exclusions rather than effective ones: within a large tree the effective set is
181+ // corrupted by dependency mediation, as an artifact excluded here may already have been pruned by a shared
182+ // node elsewhere, in which case the exclusion never fires and drops out of getEffectiveExclusions().
183+ private static Set <GroupArtifact > declaredExclusions (ResolvedDependency dep ) {
184+ Dependency requested = dep .getRequested ();
185+ List <GroupArtifact > exclusions = requested == null ? null : requested .getExclusions ();
186+ return exclusions == null || exclusions .isEmpty () ? emptySet () : new HashSet <>(exclusions );
208187 }
209188
210- private static Set <GroupArtifact > dependencyClosure (ResolvedGroupArtifactVersion gav , List <MavenRepository > repositories ,
211- MavenPomDownloader downloader , ExecutionContext ctx ,
212- Map <ResolvedGroupArtifactVersion , Set <GroupArtifact >> closureCache ) {
213- return closureCache .computeIfAbsent (gav , g -> {
214- Set <GroupArtifact > closure = new HashSet <>();
215- try {
216- for (ResolvedDependency d : resolvePom (g , repositories , downloader , ctx )
217- .resolveDependencies (Scope .Compile , downloader , ctx )) {
218- collectClosure (d , closure );
219- }
220- } catch (MavenDownloadingException | MavenDownloadingExceptions e ) {
221- // Best-effort: an unresolvable closure leaves the exclusions unfiltered
189+ private static void walkDependencies (ResolvedDependency dep , Set <ResolvedGroupArtifactVersion > visited ,
190+ Consumer <ResolvedDependency > action ) {
191+ if (visited .add (dep .getGav ())) {
192+ action .accept (dep );
193+ for (ResolvedDependency transitive : dep .getDependencies ()) {
194+ walkDependencies (transitive , visited , action );
222195 }
223- return closure ;
224- });
196+ }
225197 }
226198
227- private static ResolvedPom resolvePom (ResolvedGroupArtifactVersion gav , List <MavenRepository > repositories ,
199+ private static ResolvedPom resolvePom (GroupArtifactVersion gav , List <MavenRepository > repos ,
228200 MavenPomDownloader downloader , ExecutionContext ctx )
229201 throws MavenDownloadingException , MavenDownloadingExceptions {
230- List <MavenRepository > repos = withMavenCentral (repositories );
231- Pom pom = downloader .download (gav .asGroupArtifactVersion (), null , null , repos );
232- return pom .resolve (emptyList (), downloader , repos , ctx );
202+ return downloader .download (gav , null , null , repos ).resolve (emptyList (), downloader , repos , ctx );
233203 }
234204
235205 private static List <MavenRepository > withMavenCentral (List <MavenRepository > repositories ) {
@@ -241,11 +211,36 @@ private static List<MavenRepository> withMavenCentral(List<MavenRepository> repo
241211 return effectiveRepos ;
242212 }
243213
244- private static void collectClosure (ResolvedDependency dep , Set <GroupArtifact > closure ) {
245- if (closure .add (dep .getGav ().asGroupArtifact ())) {
246- for (ResolvedDependency transitive : dep .getDependencies ()) {
247- collectClosure (transitive , closure );
248- }
214+ // Resolves what a coordinate can bring in on its own, so that exclusions targeting artifacts it could never
215+ // have brought can be discarded before comparing two declarations.
216+ private static class ClosureResolver {
217+ private final List <MavenRepository > repositories ;
218+ private final MavenPomDownloader downloader ;
219+ private final ExecutionContext ctx ;
220+ private final Map <GroupArtifactVersion , Set <GroupArtifact >> cache ;
221+
222+ private ClosureResolver (List <MavenRepository > repositories , ExecutionContext ctx ,
223+ Map <GroupArtifactVersion , Set <GroupArtifact >> cache ) {
224+ this .repositories = withMavenCentral (repositories );
225+ this .downloader = new MavenPomDownloader (ctx );
226+ this .ctx = ctx ;
227+ this .cache = cache ;
228+ }
229+
230+ private Set <GroupArtifact > closureOf (GroupArtifactVersion gav ) {
231+ return cache .computeIfAbsent (gav , g -> {
232+ Set <GroupArtifact > closure = new HashSet <>();
233+ Set <ResolvedGroupArtifactVersion > visited = new HashSet <>();
234+ try {
235+ for (ResolvedDependency d : resolvePom (g , repositories , downloader , ctx )
236+ .resolveDependencies (Scope .Compile , downloader , ctx )) {
237+ walkDependencies (d , visited , r -> closure .add (r .getGav ().asGroupArtifact ()));
238+ }
239+ } catch (MavenDownloadingException | MavenDownloadingExceptions e ) {
240+ // Best-effort: an empty closure makes every exclusion look like a no-op
241+ }
242+ return closure ;
243+ });
249244 }
250245 }
251246
@@ -277,7 +272,7 @@ public TreeVisitor<?, ExecutionContext> getVisitor(Accumulator acc) {
277272 String projectId = gradle .getGroup () + ":" + gradle .getName ();
278273 Map <String , Set <TransitiveDependency >> scopeToTransitives =
279274 acc .transitivesByProjectAndScope .getOrDefault (projectId , emptyMap ());
280- MavenPomDownloader downloader = new MavenPomDownloader ( ctx );
275+ ClosureResolver resolver = new ClosureResolver ( gradle . getMavenRepositories (), ctx , acc . closureCache );
281276
282277 for (GradleDependencyConfiguration conf : gradle .getConfigurations ()) {
283278 Set <TransitiveDependency > transitives = getCompatibleGradleTransitives (
@@ -289,7 +284,7 @@ public TreeVisitor<?, ExecutionContext> getVisitor(Accumulator acc) {
289284 for (ResolvedDependency dep : conf .getResolved ()) {
290285 if (dep .isDirect () &&
291286 doesNotMatchArguments (dep ) &&
292- isRedundant (dep , transitives , gradle . getMavenRepositories (), downloader , ctx )) {
287+ isRedundant (dep , transitives , resolver )) {
293288 // This direct dependency is transitively provided, remove it
294289 // Don't specify configuration - Gradle's resolved config names differ from declaration names
295290 result = new RemoveDependency (
@@ -305,7 +300,7 @@ public TreeVisitor<?, ExecutionContext> getVisitor(Accumulator acc) {
305300 String projectId = maven .getPom ().getGroupId () + ":" + maven .getPom ().getArtifactId ();
306301 Map <String , Set <TransitiveDependency >> scopeToTransitives =
307302 acc .transitivesByProjectAndScope .getOrDefault (projectId , emptyMap ());
308- MavenPomDownloader downloader = new MavenPomDownloader ( ctx );
303+ ClosureResolver resolver = new ClosureResolver ( maven . getPom (). getRepositories (), ctx , acc . closureCache );
309304
310305 // A direct dependency appears under every scope bucket it is visible in; evaluate each
311306 // one once using its own effective scope so a wider transitive scope does not falsely
@@ -319,7 +314,7 @@ public TreeVisitor<?, ExecutionContext> getVisitor(Accumulator acc) {
319314 Scope depScope = Scope .fromName (dep .getRequested ().getScope ());
320315 Set <TransitiveDependency > transitives = scopeToTransitives .getOrDefault (
321316 depScope .name ().toLowerCase (), emptySet ());
322- if (isRedundant (dep , transitives , maven . getPom (). getRepositories (), downloader , ctx )) {
317+ if (isRedundant (dep , transitives , resolver )) {
323318 // This direct dependency is transitively provided at the same scope and
324319 // with the same exclusions, remove it.
325320 result = new RemoveDependency (
@@ -338,20 +333,38 @@ private boolean doesNotMatchArguments(ResolvedDependency dep) {
338333 }
339334
340335 private boolean isRedundant (ResolvedDependency dep , Set <TransitiveDependency > transitives ,
341- List <MavenRepository > repositories , MavenPomDownloader downloader , ExecutionContext ctx ) {
342- Set <GroupArtifact > depExclusions = relevantExclusions (dep , repositories , downloader , ctx , acc .closureCache );
336+ ClosureResolver resolver ) {
337+ Set <GroupArtifact > depExclusions = declaredExclusions (dep );
338+ Set <GroupArtifact > closure = null ;
343339 for (TransitiveDependency transitive : transitives ) {
344340 ResolvedGroupArtifactVersion gav = transitive .getGav ();
345341 if (dep .getGroupId ().equals (gav .getGroupId ()) &&
346342 dep .getArtifactId ().equals (gav .getArtifactId ()) &&
347- dep .getVersion ().equals (gav .getVersion ()) &&
348- depExclusions .equals (transitive .getExclusions ())) {
349- return true ;
343+ dep .getVersion ().equals (gav .getVersion ())) {
344+ if (depExclusions .equals (transitive .getExclusions ())) {
345+ return true ;
346+ }
347+ // Differing declarations can still be equivalent once exclusions that the coordinate
348+ // could never have honoured anyway are discarded. Both sides share one closure, and
349+ // every candidate here has the same coordinate, so this resolves at most once.
350+ if (closure == null ) {
351+ closure = resolver .closureOf (gav .asGroupArtifactVersion ());
352+ }
353+ if (retainClosure (depExclusions , closure ).equals (
354+ retainClosure (transitive .getExclusions (), closure ))) {
355+ return true ;
356+ }
350357 }
351358 }
352359 return false ;
353360 }
354361
362+ private Set <GroupArtifact > retainClosure (Set <GroupArtifact > exclusions , Set <GroupArtifact > closure ) {
363+ Set <GroupArtifact > relevant = new HashSet <>(exclusions );
364+ relevant .retainAll (closure );
365+ return relevant ;
366+ }
367+
355368 private Set <TransitiveDependency > getCompatibleGradleTransitives (
356369 Map <String , Set <TransitiveDependency >> scopeToTransitives ,
357370 String targetScope ) {
0 commit comments