Skip to content

Commit 0371a6c

Browse files
authored
Keep direct dependencies whose scope or exclusions differ from the transitive one (#188)
* Keep direct dependencies whose scope or exclusions differ from the transitive one RemoveRedundantDependencies removed a direct dependency whenever a matched parent provided the same coordinate transitively, ignoring scope and exclusions. This silently changed the effective classpath when the direct declaration carried information the transitive entry did not. A direct dependency is now only removed when the transitive one provides it at the exact same effective scope and with the same exclusions: - Track each transitive dependency's effective exclusions and only treat a direct dependency as redundant when its exclusions match exactly. - Key Maven transitives by the parent dependency's own effective scope and compare against the direct dependency's effective scope, rather than treating a broader transitive scope as covering a narrower direct one. Fixes the five scenarios from openrewrite/rewrite#8235. * Simplify test POMs: drop XML declaration and project attributes * Extract handleGradle/handleMaven and trim comments * Rename getCompatibleTransitives to getCompatibleGradleTransitives
1 parent 176eed3 commit 0371a6c

2 files changed

Lines changed: 284 additions & 98 deletions

File tree

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

Lines changed: 100 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,20 @@ public class RemoveRedundantDependencies extends ScanningRecipe<RemoveRedundantD
5050

5151
String description = "Remove explicit dependencies that are already provided transitively by a specified dependency. " +
5252
"This recipe downloads and resolves the parent dependency's POM to determine its true transitive " +
53-
"dependencies, allowing it to detect redundancies even when both dependencies are explicitly declared.";
53+
"dependencies, allowing it to detect redundancies even when both dependencies are explicitly declared. " +
54+
"A direct dependency is only removed when the transitive one provides it at the exact same scope and " +
55+
"with the same exclusions, so that removing it does not change the effective classpath.";
5456

5557
@Value
5658
public static class Accumulator {
57-
// Map from project identifier -> scope/configuration -> Set of transitive GAVs
58-
Map<String, Map<String, Set<ResolvedGroupArtifactVersion>>> transitivesByProjectAndScope;
59+
// Map from project identifier -> scope/configuration -> Set of transitive dependencies
60+
Map<String, Map<String, Set<TransitiveDependency>>> transitivesByProjectAndScope;
61+
}
62+
63+
@Value
64+
public static class TransitiveDependency {
65+
ResolvedGroupArtifactVersion gav;
66+
Set<GroupArtifact> exclusions;
5967
}
6068

6169
@Override
@@ -82,7 +90,7 @@ public TreeVisitor<?, ExecutionContext> getScanner(Accumulator acc) {
8290
StringUtils.matchesGlob(dep.getGroupId(), groupId) &&
8391
StringUtils.matchesGlob(dep.getArtifactId(), artifactId)) {
8492
// This is a matching parent dependency, resolve its transitives independently
85-
Set<ResolvedGroupArtifactVersion> transitives = acc.transitivesByProjectAndScope
93+
Set<TransitiveDependency> transitives = acc.transitivesByProjectAndScope
8694
.computeIfAbsent(projectId, k -> new HashMap<>())
8795
.computeIfAbsent(conf.getName(), k -> new HashSet<>());
8896
resolveTransitivesFromPom(
@@ -101,14 +109,18 @@ public TreeVisitor<?, ExecutionContext> getScanner(Accumulator acc) {
101109
String projectId = maven.getPom().getGroupId() + ":" + maven.getPom().getArtifactId();
102110
MavenPomDownloader downloader = new MavenPomDownloader(ctx);
103111

104-
for (Map.Entry<Scope, List<ResolvedDependency>> entry : maven.getDependencies().entrySet()) {
105-
Scope depScope = entry.getKey();
106-
for (ResolvedDependency dep : entry.getValue()) {
112+
// A direct dependency appears under every scope bucket it is visible in, so process
113+
// each matching parent once, keyed by its own effective (declared) scope.
114+
Set<String> processed = new HashSet<>();
115+
for (List<ResolvedDependency> deps : maven.getDependencies().values()) {
116+
for (ResolvedDependency dep : deps) {
107117
if (dep.isDirect() &&
108118
StringUtils.matchesGlob(dep.getGroupId(), groupId) &&
109-
StringUtils.matchesGlob(dep.getArtifactId(), artifactId)) {
119+
StringUtils.matchesGlob(dep.getArtifactId(), artifactId) &&
120+
processed.add(dep.getGroupId() + ":" + dep.getArtifactId())) {
110121
// This is a matching parent dependency, resolve its transitives independently
111-
Set<ResolvedGroupArtifactVersion> transitives = acc.transitivesByProjectAndScope
122+
Scope depScope = Scope.fromName(dep.getRequested().getScope());
123+
Set<TransitiveDependency> transitives = acc.transitivesByProjectAndScope
112124
.computeIfAbsent(projectId, k -> new HashMap<>())
113125
.computeIfAbsent(depScope.name().toLowerCase(), k -> new HashSet<>());
114126
resolveTransitivesFromPom(
@@ -132,7 +144,7 @@ private void resolveTransitivesFromPom(
132144
List<MavenRepository> repositories,
133145
MavenPomDownloader downloader,
134146
ExecutionContext ctx,
135-
Set<ResolvedGroupArtifactVersion> transitives) {
147+
Set<TransitiveDependency> transitives) {
136148
try {
137149
// Ensure we have Maven Central in the repositories
138150
List<MavenRepository> effectiveRepos = new ArrayList<>(repositories);
@@ -148,8 +160,9 @@ private void resolveTransitivesFromPom(
148160
List<ResolvedDependency> resolved = patchedPom.resolveDependencies(Scope.Compile, downloader, ctx);
149161

150162
// Collect all dependencies (both direct and transitive of the parent)
163+
Set<ResolvedGroupArtifactVersion> visited = new HashSet<>();
151164
for (ResolvedDependency dep : resolved) {
152-
collectAllDependencies(dep, transitives);
165+
collectAllDependencies(dep, transitives, visited);
153166
}
154167
} catch (MavenDownloadingException | MavenDownloadingExceptions e) {
155168
// If we can't download/resolve the POM, fall back to not detecting redundancies
@@ -166,10 +179,12 @@ private ResolvedPom applyExclusions(ResolvedPom resolvedPom, List<GroupArtifact>
166179
return patchedPom;
167180
}
168181

169-
private void collectAllDependencies(ResolvedDependency dep, Set<ResolvedGroupArtifactVersion> transitives) {
170-
if (transitives.add(dep.getGav())) {
182+
private void collectAllDependencies(ResolvedDependency dep, Set<TransitiveDependency> transitives,
183+
Set<ResolvedGroupArtifactVersion> visited) {
184+
if (visited.add(dep.getGav())) {
185+
transitives.add(new TransitiveDependency(dep.getGav(), new HashSet<>(dep.getEffectiveExclusions())));
171186
for (ResolvedDependency transitive : dep.getDependencies()) {
172-
collectAllDependencies(transitive, transitives);
187+
collectAllDependencies(transitive, transitives, visited);
173188
}
174189
}
175190
}
@@ -185,112 +200,112 @@ public TreeVisitor<?, ExecutionContext> getVisitor(Accumulator acc) {
185200
return tree;
186201
}
187202

188-
SourceFile sf = (SourceFile) tree;
189-
Tree result = sf;
190-
191-
// Handle Gradle
192-
Optional<GradleProject> gradleOpt = sf.getMarkers().findFirst(GradleProject.class);
203+
Optional<GradleProject> gradleOpt = tree.getMarkers().findFirst(GradleProject.class);
193204
if (gradleOpt.isPresent()) {
194205
GradleProject gradle = gradleOpt.get();
195-
String projectId = gradle.getGroup() + ":" + gradle.getName();
196-
Map<String, Set<ResolvedGroupArtifactVersion>> scopeToTransitives =
197-
acc.transitivesByProjectAndScope.getOrDefault(projectId, emptyMap());
198-
199-
for (GradleDependencyConfiguration conf : gradle.getConfigurations()) {
200-
Set<ResolvedGroupArtifactVersion> transitives = getCompatibleTransitives(
201-
scopeToTransitives, conf.getName(), true);
202-
if (transitives.isEmpty()) {
203-
continue;
204-
}
205-
206-
for (ResolvedDependency dep : conf.getResolved()) {
207-
if (dep.isDirect() &&
208-
doesNotMatchArguments(dep) &&
209-
isInTransitives(dep, transitives)) {
210-
// This direct dependency is transitively provided, remove it
211-
// Don't specify configuration - Gradle's resolved config names differ from declaration names
212-
result = new RemoveDependency(
213-
dep.getGroupId(), dep.getArtifactId(), null, null, null)
214-
.getVisitor().visit(result, ctx);
215-
}
216-
}
217-
}
218-
return result;
206+
return handleGradle(ctx, gradle, tree);
219207
}
220208

221-
// Handle Maven
222-
Optional<MavenResolutionResult> mavenOpt = sf.getMarkers().findFirst(MavenResolutionResult.class);
209+
Optional<MavenResolutionResult> mavenOpt = tree.getMarkers().findFirst(MavenResolutionResult.class);
223210
if (mavenOpt.isPresent()) {
224211
MavenResolutionResult maven = mavenOpt.get();
225-
String projectId = maven.getPom().getGroupId() + ":" + maven.getPom().getArtifactId();
226-
Map<String, Set<ResolvedGroupArtifactVersion>> scopeToTransitives =
227-
acc.transitivesByProjectAndScope.getOrDefault(projectId, emptyMap());
228-
229-
for (Map.Entry<Scope, List<ResolvedDependency>> entry : maven.getDependencies().entrySet()) {
230-
String scope = entry.getKey().name().toLowerCase();
231-
Set<ResolvedGroupArtifactVersion> transitives = getCompatibleTransitives(
232-
scopeToTransitives, scope, false);
233-
if (transitives.isEmpty()) {
234-
continue;
212+
return handleMaven(ctx, maven, tree);
213+
}
214+
215+
return tree;
216+
}
217+
218+
private @Nullable Tree handleGradle(ExecutionContext ctx, GradleProject gradle, Tree result) {
219+
String projectId = gradle.getGroup() + ":" + gradle.getName();
220+
Map<String, Set<TransitiveDependency>> scopeToTransitives =
221+
acc.transitivesByProjectAndScope.getOrDefault(projectId, emptyMap());
222+
223+
for (GradleDependencyConfiguration conf : gradle.getConfigurations()) {
224+
Set<TransitiveDependency> transitives = getCompatibleGradleTransitives(
225+
scopeToTransitives, conf.getName());
226+
if (transitives.isEmpty()) {
227+
continue;
228+
}
229+
230+
for (ResolvedDependency dep : conf.getResolved()) {
231+
if (dep.isDirect() &&
232+
doesNotMatchArguments(dep) &&
233+
isRedundant(dep, transitives)) {
234+
// This direct dependency is transitively provided, remove it
235+
// Don't specify configuration - Gradle's resolved config names differ from declaration names
236+
result = new RemoveDependency(
237+
dep.getGroupId(), dep.getArtifactId(), null, null, null)
238+
.getVisitor().visit(result, ctx);
235239
}
240+
}
241+
}
242+
return result;
243+
}
236244

237-
for (ResolvedDependency dep : entry.getValue()) {
238-
if (dep.isDirect() &&
239-
doesNotMatchArguments(dep) &&
240-
isInTransitives(dep, transitives)) {
241-
// This direct dependency is transitively provided, remove it
245+
private @Nullable Tree handleMaven(ExecutionContext ctx, MavenResolutionResult maven, Tree result) {
246+
String projectId = maven.getPom().getGroupId() + ":" + maven.getPom().getArtifactId();
247+
Map<String, Set<TransitiveDependency>> scopeToTransitives =
248+
acc.transitivesByProjectAndScope.getOrDefault(projectId, emptyMap());
249+
250+
// A direct dependency appears under every scope bucket it is visible in; evaluate each
251+
// one once using its own effective scope so a wider transitive scope does not falsely
252+
// mark a narrower direct declaration as redundant.
253+
Set<String> processed = new HashSet<>();
254+
for (List<ResolvedDependency> deps : maven.getDependencies().values()) {
255+
for (ResolvedDependency dep : deps) {
256+
if (dep.isDirect() &&
257+
doesNotMatchArguments(dep) &&
258+
processed.add(dep.getGroupId() + ":" + dep.getArtifactId())) {
259+
Scope depScope = Scope.fromName(dep.getRequested().getScope());
260+
Set<TransitiveDependency> transitives = scopeToTransitives.getOrDefault(
261+
depScope.name().toLowerCase(), emptySet());
262+
if (isRedundant(dep, transitives)) {
263+
// This direct dependency is transitively provided at the same scope and
264+
// with the same exclusions, remove it.
242265
result = new RemoveDependency(
243-
dep.getGroupId(), dep.getArtifactId(), null, null, scope)
266+
dep.getGroupId(), dep.getArtifactId(), null, null, depScope.name().toLowerCase())
244267
.getVisitor().visit(result, ctx);
245268
}
246269
}
247270
}
248-
return result;
249271
}
250-
251-
return tree;
272+
return result;
252273
}
253274

254275
private boolean doesNotMatchArguments(ResolvedDependency dep) {
255276
return !StringUtils.matchesGlob(dep.getGroupId(), groupId) ||
256277
!StringUtils.matchesGlob(dep.getArtifactId(), artifactId);
257278
}
258279

259-
private boolean isInTransitives(ResolvedDependency dep, Set<ResolvedGroupArtifactVersion> transitives) {
260-
// Check if this dependency's GAV matches any transitive
261-
// We match on groupId:artifactId:version exactly
262-
for (ResolvedGroupArtifactVersion transitive : transitives) {
263-
if (dep.getGroupId().equals(transitive.getGroupId()) &&
264-
dep.getArtifactId().equals(transitive.getArtifactId()) &&
265-
dep.getVersion().equals(transitive.getVersion())) {
280+
private boolean isRedundant(ResolvedDependency dep, Set<TransitiveDependency> transitives) {
281+
Set<GroupArtifact> depExclusions = new HashSet<>(dep.getEffectiveExclusions());
282+
for (TransitiveDependency transitive : transitives) {
283+
ResolvedGroupArtifactVersion gav = transitive.getGav();
284+
if (dep.getGroupId().equals(gav.getGroupId()) &&
285+
dep.getArtifactId().equals(gav.getArtifactId()) &&
286+
dep.getVersion().equals(gav.getVersion()) &&
287+
depExclusions.equals(transitive.getExclusions())) {
266288
return true;
267289
}
268290
}
269291
return false;
270292
}
271293

272-
/**
273-
* Get transitives from this scope/configuration and any broader ones.
274-
*/
275-
private Set<ResolvedGroupArtifactVersion> getCompatibleTransitives(
276-
Map<String, Set<ResolvedGroupArtifactVersion>> scopeToTransitives,
277-
String targetScope,
278-
boolean isGradle) {
294+
private Set<TransitiveDependency> getCompatibleGradleTransitives(
295+
Map<String, Set<TransitiveDependency>> scopeToTransitives,
296+
String targetScope) {
279297

280-
Set<ResolvedGroupArtifactVersion> result = new HashSet<>();
298+
Set<TransitiveDependency> result = new HashSet<>();
281299

282300
// Always include transitives from the same scope
283-
Set<ResolvedGroupArtifactVersion> sameScope = scopeToTransitives.get(targetScope);
301+
Set<TransitiveDependency> sameScope = scopeToTransitives.get(targetScope);
284302
if (sameScope != null) {
285303
result.addAll(sameScope);
286304
}
287305

288306
// Include transitives from broader scopes
289-
List<String> broaderScopes = isGradle ?
290-
getBroaderGradleScopes(targetScope) :
291-
getBroaderMavenScopes(targetScope);
292-
for (String broader : broaderScopes) {
293-
Set<ResolvedGroupArtifactVersion> broaderTransitives = scopeToTransitives.get(broader);
307+
for (String broader : getBroaderGradleScopes(targetScope)) {
308+
Set<TransitiveDependency> broaderTransitives = scopeToTransitives.get(broader);
294309
if (broaderTransitives != null) {
295310
result.addAll(broaderTransitives);
296311
}
@@ -313,19 +328,6 @@ private List<String> getBroaderGradleScopes(String scope) {
313328
return emptyList();
314329
}
315330
}
316-
317-
private List<String> getBroaderMavenScopes(String scope) {
318-
switch (scope.toLowerCase()) {
319-
case "runtime":
320-
return singletonList("compile");
321-
case "provided":
322-
return Arrays.asList("compile", "runtime");
323-
case "test":
324-
return Arrays.asList("compile", "runtime", "provided");
325-
default:
326-
return emptyList();
327-
}
328-
}
329331
};
330332
}
331333
}

0 commit comments

Comments
 (0)