MavenWrapperReconciler.java
package network.ike.plugin.ws.reconcile;
import network.ike.plugin.ws.MavenWrapper;
import network.ike.plugin.ws.WsGoal;
import network.ike.workspace.Defaults;
import network.ike.workspace.Subproject;
import org.apache.maven.api.plugin.MojoException;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Reconciler that keeps each subproject's Maven wrapper pinned to the
* workspace's declared Maven version (IKE-Network/ike-issues#701).
*
* <p>{@code workspace.yaml} declares {@code defaults.maven-version}
* (e.g. {@code 4.0.0-rc-5}); a subproject may override it with its own
* {@code maven-version}. Nothing previously enforced that a subproject's
* {@code .mvn/wrapper/maven-wrapper.properties} actually matches. A
* subproject onboarded with an older wrapper (e.g. {@code tinkar-schema}
* carrying Maven 3.9.11) silently stayed pinned to the wrong version,
* and {@code ws:scaffold-draft}/{@code -publish} then failed because the
* workspace enforcer requires Maven 4+.
*
* <p>This reconciler closes that gap: it reads the pinned version from
* each subproject's wrapper, compares it against the expected version
* (subproject override, else {@code defaults.maven-version}), reports
* drift in draft mode, and rewrites the {@code distributionUrl} (and the
* legacy {@code maven.version} key, when present) in publish mode.
*
* <p>Scope is the <em>version</em> only — {@link ScaffoldConventionReconciler}
* owns wrapper <em>presence</em> (regenerating missing files and replacing
* the legacy custom launcher), but only for the workspace root and only
* when files are absent or legacy; it never re-pins the version of a
* well-formed subproject wrapper. The two reconcilers are complementary.
*
* <p>Subprojects without a wrapper file are skipped (no presence
* enforcement here). Idempotent: a second run is a no-op. Opt out with
* {@code -DupdateWrapper=false}.
*
* @see MavenWrapper#rewritePinnedVersion
* @see ScaffoldConventionReconciler
*/
public class MavenWrapperReconciler implements Reconciler {
/** Creates the reconciler. */
public MavenWrapperReconciler() {}
@Override
public String dimension() {
return "Maven wrapper version";
}
@Override
public String optOutFlag() {
return "updateWrapper";
}
@Override
public DriftReport detect(WorkspaceContext ctx) {
List<Change> changes = computeChanges(ctx);
if (changes.isEmpty()) {
return DriftReport.noDrift(dimension());
}
List<String> detail = new ArrayList<>();
for (Change c : changes) {
detail.add(c.name() + ": " + c.actual() + " → " + c.expected());
}
String summary = changes.size()
+ " wrapper(s) pinned to the wrong Maven version";
String action = "rewrite each subproject's maven-wrapper.properties "
+ "to the workspace Maven version on scaffold-publish";
String optOut = "mvn " + WsGoal.SCAFFOLD_PUBLISH.qualified()
+ " -D" + optOutFlag() + "=false";
return new DriftReport(dimension(), true, summary, detail,
action, optOut);
}
@Override
public void apply(WorkspaceContext ctx) {
if (ctx.options().isOptedOut(optOutFlag())) {
ctx.log().info(" " + dimension() + ": skipped (opted out via -D"
+ optOutFlag() + "=false)");
return;
}
List<Change> changes = computeChanges(ctx);
if (changes.isEmpty()) {
return;
}
int rewritten = 0;
for (Change c : changes) {
Path dir = new File(ctx.workspaceRoot(), c.name()).toPath();
try {
if (MavenWrapper.rewritePinnedVersion(dir, c.expected())) {
rewritten++;
}
} catch (IOException e) {
throw new MojoException("Maven wrapper reconciliation failed "
+ "for " + c.name() + ": " + e.getMessage(), e);
}
}
ctx.log().info(" " + dimension() + ": pinned " + rewritten
+ " wrapper(s) to the workspace Maven version");
}
// ── Change computation (shared by detect and apply) ─────────────
/**
* One subproject's wrapper-version drift.
*
* @param name the subproject name
* @param actual the version its wrapper currently pins
* @param expected the version it should pin (override or default)
*/
private record Change(String name, String actual, String expected) {}
/**
* Compute the wrapper-version drift across all subprojects. The
* expected version is the subproject's own {@code maven-version}
* override when set, otherwise {@code defaults.maven-version}.
* Subprojects with no expected version, no wrapper file, or an
* unreadable wrapper are skipped.
*
* @param ctx the workspace context
* @return the list of drifted subprojects (empty when converged)
*/
private List<Change> computeChanges(WorkspaceContext ctx) {
Defaults defaults = ctx.graph().manifest().defaults();
String defaultVersion =
defaults != null ? defaults.mavenVersion() : null;
List<Change> changes = new ArrayList<>();
for (Map.Entry<String, Subproject> entry
: ctx.graph().manifest().subprojects().entrySet()) {
String name = entry.getKey();
String override = entry.getValue().mavenVersion();
String expected = (override != null && !override.isBlank())
? override : defaultVersion;
if (expected == null || expected.isBlank()) {
continue;
}
Path dir = new File(ctx.workspaceRoot(), name).toPath();
Path props = dir.resolve(".mvn").resolve("wrapper")
.resolve("maven-wrapper.properties");
if (!Files.exists(props)) {
continue;
}
String actual;
try {
actual = MavenWrapper.readPinnedVersion(dir);
} catch (IOException e) {
ctx.log().warn(" " + name + ": cannot read wrapper version — "
+ e.getMessage());
continue;
}
if (actual == null || actual.isBlank()) {
continue;
}
if (!expected.equals(actual)) {
changes.add(new Change(name, actual, expected));
}
}
return changes;
}
}