VerifyConvergenceMojo.java
package network.ike.plugin.ws;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.workspace.Subproject;
import network.ike.workspace.DependencyConvergenceAnalysis;
import network.ike.workspace.DependencyConvergenceAnalysis.Divergence;
import network.ike.workspace.DependencyTreeParser;
import network.ike.workspace.DependencyTreeParser.ResolvedDependency;
import network.ike.workspace.VersionSupport;
import network.ike.workspace.WorkspaceGraph;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
/**
* Check transitive dependency convergence across workspace subprojects.
*
* <p>This goal runs {@code mvn dependency:tree} for each subproject in
* topological order, then compares resolved versions of shared
* dependencies. Divergences (the same artifact resolving to different
* versions in different components) are reported in the terminal and
* written to a markdown report.
*
* <p>This is inherently read-only — no apply variant is needed.
* Slower than other verification goals because it invokes Maven
* per subproject.
*
* <pre>{@code
* mvn ws:verify-convergence
* mvn ws:verify-convergence -DconvergenceReport=build/convergence.md
* }</pre>
*
* <p>For general workspace verification, see
* {@link WsScaffoldDraftMojo} (which now folds verify per #393).
*/
@Mojo(name = "verify-convergence", projectRequired = false, aggregator = true)
public class VerifyConvergenceMojo extends AbstractWorkspaceMojo {
/**
* Output file for the convergence markdown report. Defaults to
* {@code target/convergence-report.md} in the workspace root.
*/
@Parameter(property = "convergenceReport")
String convergenceReport;
/** Creates this goal instance. */
public VerifyConvergenceMojo() {}
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
getLog().info("");
getLog().info(header("Dependency Convergence"));
getLog().info("══════════════════════════════════════════════════════════════");
WorkspaceGraph graph = loadGraph();
File root = workspaceRoot();
boolean failed = false;
// ── Fast pre-checks (no Maven invocations) ──────────────
boolean parentSkew = checkParentVersionSkew(graph, root);
boolean qualifierContamination = checkBranchQualifierContamination(graph, root);
failed |= parentSkew;
failed |= qualifierContamination;
// ── Dependency tree convergence (slow) ──────────────────
getLog().info("");
getLog().info(" Resolving dependency trees (this may take a while)...");
getLog().info("");
File mvnExecutable = ReleaseSupport.resolveMavenWrapper(root, getLog());
// Collect dependency trees per subproject in topological order
List<String> order = graph.topologicalSort();
Map<String, List<ResolvedDependency>> componentTrees =
new LinkedHashMap<>();
for (String name : order) {
File subDir = new File(root, name);
File pomFile = new File(subDir, "pom.xml");
if (!pomFile.exists()) continue;
getLog().info(" Resolving " + name + "...");
try {
String treeOutput = ReleaseSupport.execCapture(subDir,
mvnExecutable.getAbsolutePath(),
"dependency:tree", "-DoutputType=text",
"-B", "-q");
List<ResolvedDependency> deps =
DependencyTreeParser.parse(treeOutput);
if (!deps.isEmpty()) {
componentTrees.put(name, deps);
}
} catch (MojoException e) {
getLog().warn(" ⚠ " + name + ": dependency:tree failed — "
+ e.getMessage());
}
}
if (componentTrees.size() < 2) {
getLog().info(" Fewer than 2 components resolved — skipping analysis");
return new WorkspaceReportSpec(WsGoal.VERIFY_CONVERGENCE, buildSummary(
workspaceName(), componentTrees.size(),
parentSkew, qualifierContamination,
java.util.List.of(), failed));
}
// Analyze
List<Divergence> divergences =
DependencyConvergenceAnalysis.analyze(componentTrees);
// Terminal output
if (divergences.isEmpty()) {
getLog().info("");
getLog().info(" Convergence: all shared dependencies converge across "
+ componentTrees.size() + " components ✓");
} else {
getLog().info("");
getLog().info(" Convergence: " + divergences.size()
+ " artifact(s) diverge across "
+ componentTrees.size() + " components");
getLog().info("");
for (Divergence d : divergences) {
getLog().info(" " + d.coordinate());
for (var vEntry : d.versionToSubprojects().entrySet()) {
getLog().info(" " + vEntry.getKey() + " ← "
+ String.join(", ", vEntry.getValue()));
}
}
}
// Markdown report
String wsName = workspaceName();
String markdown = divergences.isEmpty()
? "# Dependency Convergence — " + wsName + "\n\n"
+ "All shared dependencies converge across "
+ componentTrees.size() + " components.\n"
: DependencyConvergenceAnalysis.formatMarkdownReport(
divergences, wsName);
Path reportPath = resolveConvergenceReportPath(root);
try {
Files.createDirectories(reportPath.getParent());
Files.writeString(reportPath, markdown, StandardCharsets.UTF_8);
getLog().info("");
getLog().info(" Report: " + reportPath);
} catch (IOException e) {
getLog().warn(" Could not write convergence report: "
+ e.getMessage());
}
if (!divergences.isEmpty()) {
failed = true;
}
getLog().info("");
if (failed) {
throw new MojoException(
"Convergence verification failed — see output above.");
}
return new WorkspaceReportSpec(WsGoal.VERIFY_CONVERGENCE, buildSummary(
wsName, componentTrees.size(),
parentSkew, qualifierContamination,
divergences, failed));
}
/**
* Build the session report summary. A higher-level view than the
* configurable {@code convergenceReport} markdown: captures all three
* finding buckets (parent skew, qualifier contamination, transitive
* divergence) and an overall pass/fail status.
*
* @param wsName workspace name for the heading
* @param componentsResolved number of components whose dependency tree resolved
* @param parentSkew whether the parent-version check found mismatches
* @param qualifierContamination whether branch-qualifier contamination was found
* @param divergences transitive dependency divergences (may be empty)
* @param failed overall pass/fail flag
* @return structured markdown for the session report
*/
static String buildSummary(String wsName,
int componentsResolved,
boolean parentSkew,
boolean qualifierContamination,
List<Divergence> divergences,
boolean failed) {
GoalReportBuilder report = new GoalReportBuilder();
report.section("Dependency Convergence — " + wsName)
.paragraph("**Components resolved:** " + componentsResolved)
.table(List.of("Check", "Result"), List.of(
new String[]{"Parent version skew",
parentSkew ? "❌ mismatches" : "✓ clean"},
new String[]{"Branch qualifier contamination",
qualifierContamination ? "❌ found" : "✓ clean"},
new String[]{"Dependency convergence",
divergences.isEmpty() ? "✓ clean"
: "❌ " + divergences.size()
+ " divergence(s)"}))
.paragraph("**Overall:** " + (failed ? "FAIL" : "PASS"));
if (!divergences.isEmpty()) {
report.section("Divergences");
for (Divergence d : divergences) {
StringBuilder item = new StringBuilder();
item.append("`").append(d.coordinate()).append("`");
for (var vEntry : d.versionToSubprojects().entrySet()) {
item.append("\n - `").append(vEntry.getKey()).append("` ← ")
.append(String.join(", ", vEntry.getValue()));
}
report.bullet(item.toString());
}
}
return report.build();
}
// ── Parent version skew check ───────────────────────────────
/**
* Check that all subproject POMs reference the same ike-parent
* version as the root POM. Logs a warning for each mismatch.
*
* @param graph workspace graph
* @param root workspace root directory
* @return {@code true} if any skew was detected
*/
private boolean checkParentVersionSkew(WorkspaceGraph graph, File root) {
Path rootPom = root.toPath().resolve("pom.xml");
if (!Files.exists(rootPom)) return false;
PomParentSupport.ParentInfo rootParent;
try {
rootParent = PomParentSupport.readParent(rootPom);
} catch (IOException e) {
return false;
}
if (rootParent == null) return false;
String rootVersion = rootParent.version();
String parentAid = rootParent.artifactId();
List<String> skewed = new ArrayList<>();
getLog().info("");
getLog().info(" Parent version check (" + parentAid + ":" + rootVersion + ")");
for (Map.Entry<String, Subproject> entry
: graph.manifest().subprojects().entrySet()) {
String name = entry.getKey();
File subDir = new File(root, name);
Path compPom = subDir.toPath().resolve("pom.xml");
if (!Files.exists(compPom)) continue;
try {
PomParentSupport.ParentInfo compParent =
PomParentSupport.readParent(compPom);
if (compParent == null) continue;
if (!parentAid.equals(compParent.artifactId())) continue;
if (!rootVersion.equals(compParent.version())) {
skewed.add(name + " (" + compParent.version() + ")");
}
} catch (IOException e) {
getLog().debug(" Could not read parent for " + name);
}
}
if (skewed.isEmpty()) {
getLog().info(" " + Ansi.GREEN + "✓ " + Ansi.RESET
+ "All components match root parent version");
return false;
}
getLog().warn("");
getLog().warn(" Parent version skew detected:");
getLog().warn(" Root POM: " + parentAid + ":" + rootVersion);
for (String s : skewed) {
getLog().warn(" " + s + " ← mismatch");
}
getLog().warn(" Use ws:scaffold-publish -DparentVersion="
+ rootVersion + " to cascade.");
return true;
}
// ── Branch qualifier contamination check ────────────────────
/**
* Check for branch-qualifier version strings on non-feature branches.
* Scans all subproject POM files for version strings containing
* known feature branch qualifiers.
*
* @param graph workspace graph
* @param root workspace root directory
* @return {@code true} if any contamination was detected
*/
private boolean checkBranchQualifierContamination(
WorkspaceGraph graph, File root) {
// Only check on non-feature branches
String wsBranch = "unknown";
if (new File(root, ".git").exists()) {
wsBranch = gitBranch(root);
}
if (wsBranch.startsWith("feature/")) {
getLog().info("");
getLog().info(" Branch qualifier check: skipped (on feature branch)");
return false;
}
// Collect known feature branch qualifiers from subproject branches
Set<String> qualifiers = new TreeSet<>();
for (Map.Entry<String, Subproject> entry
: graph.manifest().subprojects().entrySet()) {
File subDir = new File(root, entry.getKey());
if (!new File(subDir, ".git").exists()) continue;
try {
String output = ReleaseSupport.execCapture(subDir,
"git", "branch", "--list", "feature/*");
for (String line : output.split("\n")) {
String branch = line.trim().replaceFirst("^\\* ", "");
if (branch.startsWith("feature/")) {
qualifiers.add(
VersionSupport.safeBranchName(branch));
}
}
} catch (MojoException ignored) {
}
}
if (qualifiers.isEmpty()) {
getLog().info("");
getLog().info(" Branch qualifier check: no feature branches found");
return false;
}
getLog().info("");
getLog().info(" Branch qualifier check (on " + wsBranch
+ ", scanning for " + qualifiers.size() + " qualifier(s))");
List<String> contaminated = new ArrayList<>();
for (Map.Entry<String, Subproject> entry
: graph.manifest().subprojects().entrySet()) {
String name = entry.getKey();
File subDir = new File(root, name);
if (!new File(subDir, "pom.xml").exists()) continue;
for (String qualifier : qualifiers) {
List<String> hits = FeatureFinishSupport
.findQualifierContamination(subDir, qualifier);
for (String hit : hits) {
contaminated.add(name + "/" + hit
+ " (qualifier: " + qualifier + ")");
}
}
}
if (contaminated.isEmpty()) {
getLog().info(" " + Ansi.GREEN + "✓ " + Ansi.RESET
+ "No branch qualifiers found on " + wsBranch);
return false;
}
getLog().warn("");
getLog().warn(" Branch qualifier contamination on " + wsBranch + ":");
for (String c : contaminated) {
getLog().warn(" " + c);
}
return true;
}
private Path resolveConvergenceReportPath(File root) {
if (convergenceReport != null && !convergenceReport.isBlank()) {
return Path.of(convergenceReport);
}
return root.toPath().resolve("target").resolve("convergence-report.md");
}
}