DependencyConvergenceAnalysis.java
package network.ike.workspace;
import network.ike.workspace.DependencyTreeParser.ResolvedDependency;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
/**
* Compare resolved dependency trees across workspace subprojects to
* detect version divergence.
*
* <p>When multiple subprojects in a workspace resolve the same
* {@code groupId:artifactId} to different versions, that divergence
* can cause classpath conflicts in assembled applications (e.g.,
* a desktop application that depends on several workspace libraries).
*
* <p>This analysis operates on the <em>resolved</em> dependency trees
* (output of {@code mvn dependency:tree}), not the declared POMs.
* This means it catches divergence introduced by transitive
* dependencies, BOM imports, and Maven's nearest-wins resolution.
*/
public final class DependencyConvergenceAnalysis {
private DependencyConvergenceAnalysis() {}
/**
* A dependency whose resolved version differs across subprojects.
*
* @param groupId Maven groupId
* @param artifactId Maven artifactId
* @param versionToSubprojects map from resolved version to the list
* of subproject names that resolve to it
*/
public record Divergence(
String groupId, String artifactId,
Map<String, List<String>> versionToSubprojects) {
/**
* The artifact coordinate as {@code groupId:artifactId}.
*
* @return the coordinate string
*/
public String coordinate() {
return groupId + ":" + artifactId;
}
/**
* Number of distinct versions found.
*
* @return the version count
*/
public int versionCount() {
return versionToSubprojects.size();
}
}
/**
* Analyze dependency trees from multiple subprojects for version
* divergence.
*
* <p>For each unique {@code groupId:artifactId} that appears in
* more than one subproject's tree, checks whether the resolved
* version is the same. Returns only those artifacts where at
* least two different versions are resolved.
*
* <p>The root artifact of each subproject (depth 0) is excluded
* from comparison — those are expected to differ.
*
* @param subprojectTrees map from subproject name to its parsed
* dependency tree
* @return list of divergences, sorted by coordinate
*/
public static List<Divergence> analyze(
Map<String, List<ResolvedDependency>> subprojectTrees) {
// Key: "groupId:artifactId" → inner map: "subprojectName" → "version"
Map<String, Map<String, String>> artifactVersions = new LinkedHashMap<>();
for (var entry : subprojectTrees.entrySet()) {
String subprojectName = entry.getKey();
for (ResolvedDependency dep : entry.getValue()) {
// Skip root artifacts
if (dep.depth() == 0) continue;
// Skip test-scoped dependencies
if ("test".equals(dep.scope())) continue;
String key = dep.groupId() + ":" + dep.artifactId();
artifactVersions
.computeIfAbsent(key, k -> new LinkedHashMap<>())
.put(subprojectName, dep.version());
}
}
// Find divergences
List<Divergence> divergences = new ArrayList<>();
for (var entry : artifactVersions.entrySet()) {
Map<String, String> subprojectVersions = entry.getValue();
if (subprojectVersions.size() < 2) continue;
// Group subprojects by version
Map<String, List<String>> versionToSubprojects =
subprojectVersions.entrySet().stream()
.collect(Collectors.groupingBy(
Map.Entry::getValue,
TreeMap::new,
Collectors.mapping(
Map.Entry::getKey,
Collectors.toList())));
if (versionToSubprojects.size() > 1) {
String[] parts = entry.getKey().split(":", 2);
divergences.add(new Divergence(
parts[0], parts[1], versionToSubprojects));
}
}
// Sort by coordinate for stable output
divergences.sort((a, b) -> a.coordinate().compareTo(b.coordinate()));
return divergences;
}
/**
* Format divergences as a markdown report suitable for rendering
* as readable plugin output.
*
* <p>Produces a markdown document with a summary table and
* per-artifact detail sections. The format is designed to be
* readable both in terminal output and when rendered as HTML.
*
* @param divergences the divergences to report
* @param workspaceName the workspace name for the report title
* @return markdown string, or empty string if no divergences
*/
public static String formatMarkdownReport(
List<Divergence> divergences, String workspaceName) {
if (divergences.isEmpty()) {
return "";
}
StringBuilder md = new StringBuilder();
md.append("# Dependency Convergence — ").append(workspaceName).append("\n\n");
md.append("**").append(divergences.size())
.append(" artifact(s)** resolve to different versions ")
.append("across workspace subprojects.\n\n");
// Summary table
md.append("| Artifact | Versions | Subprojects |\n");
md.append("|----------|----------|-------------|\n");
for (Divergence d : divergences) {
String versions = String.join(", ",
d.versionToSubprojects().keySet());
int subprojectCount = d.versionToSubprojects().values().stream()
.mapToInt(List::size).sum();
md.append("| `").append(d.coordinate()).append("` | ")
.append(versions).append(" | ")
.append(subprojectCount).append(" |\n");
}
// Detail sections
md.append("\n---\n\n");
md.append("## Details\n\n");
for (Divergence d : divergences) {
md.append("### `").append(d.coordinate()).append("`\n\n");
for (var vEntry : d.versionToSubprojects().entrySet()) {
md.append("**").append(vEntry.getKey()).append("**");
md.append(" — ");
md.append(String.join(", ", vEntry.getValue()));
md.append("\n\n");
}
}
return md.toString();
}
}