Skip to content

Commit fe1db73

Browse files
committed
Resolve dependency closures lazily when comparing exclusions
Closure resolution was eager: every visited node carrying an inherited exclusion triggered a POM download, resolve and compile-scope graph resolution. Since ancestor exclusions are propagated into each child's requested dependency, the "no exclusions" early-out rarely fired, so a spring-boot-starter tree performed ~38 extra resolutions of which one was ever consulted. TransitiveDependency now carries declared exclusions, and the closure intersection is deferred into isRedundant, where it runs only for a coordinate that actually matched and only when the two declared sets differ. Both sides share a single closure for that coordinate. Also collapse the (repositories, downloader, ctx, cache) tuple into a ClosureResolver, dedupe the two recursive dependency walks, key the cache on GroupArtifactVersion so it is not repository-sensitive, and correct the description and comments that described the abandoned mechanism.
1 parent 2d49d4a commit fe1db73

2 files changed

Lines changed: 85 additions & 71 deletions

File tree

src/main/java/org/openrewrite/java/dependencies/RemoveRedundantDependencies.java

Lines changed: 82 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import org.openrewrite.maven.tree.*;
3030

3131
import java.util.*;
32+
import java.util.function.Consumer;
3233

3334
import 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) {

src/test/java/org/openrewrite/java/dependencies/RemoveRedundantDependenciesTest.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import org.junit.jupiter.api.Test;
1919
import org.openrewrite.DocumentExample;
20+
import org.openrewrite.Issue;
2021
import org.openrewrite.test.RewriteTest;
2122

2223
import static org.openrewrite.gradle.Assertions.buildGradle;
@@ -657,9 +658,9 @@ void removesTomcatEmbedCoreWhenExclusionsMatchTransitive() {
657658
);
658659
}
659660

661+
@Issue("https://github.com/openrewrite/rewrite/issues/8336")
660662
@Test
661663
void keepsWebsocketWhenDirectHasNoExclusionButTransitiveDoes() {
662-
// https://github.com/openrewrite/rewrite/issues/8336
663664
// spring-boot-starter-tomcat excludes tomcat-annotations-api from tomcat-embed-websocket, which still
664665
// brings its own tomcat-embed-core. A direct websocket with no exclusion is therefore NOT equivalent to
665666
// the transitively-provided one and must be kept, even though websocket's effective-exclusion set is
@@ -697,9 +698,9 @@ void keepsWebsocketWhenDirectHasNoExclusionButTransitiveDoes() {
697698
);
698699
}
699700

701+
@Issue("https://github.com/openrewrite/rewrite/issues/8336")
700702
@Test
701703
void removesWebsocketWhenDirectRepeatsTheTransitiveExclusion() {
702-
// https://github.com/openrewrite/rewrite/issues/8336
703704
// The mirror image: a direct websocket that repeats the same tomcat-annotations-api exclusion the
704705
// starter applies is truly equivalent to the transitively-provided one and should be removed.
705706
rewriteRun(

0 commit comments

Comments
 (0)