AlignmentReconciler.java
package network.ike.plugin.ws.reconcile;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.ws.PomModel;
import network.ike.workspace.Dependency;
import network.ike.workspace.PublishedArtifactSet;
import network.ike.workspace.Subproject;
import network.ike.workspace.WorkspaceGraph;
import org.apache.maven.api.model.Parent;
import org.apache.maven.api.model.Plugin;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.MojoException;
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;
/**
* Reconciler that aligns inter-subproject dependency, plugin, and
* parent versions across every cloned subproject's POMs to the
* authoritative versions declared by the workspace.
*
* <p>Subsumes the {@code ws:align-{draft,publish}} business logic
* (IKE-Network/ike-issues#393). Unlike most other reconciler
* migrations, the standalone {@code ws:align-draft} and
* {@code ws:align-publish} Maven goals are <em>retained</em> as thin
* wrappers over this reconciler: alignment drift arises from many
* distinct moments (post-release cascade, manual POM edits, feature
* work, scaffold drift), so the reconciler is also invoked from
* {@code ws:scaffold-publish} as part of the convergence pass. The
* standalone goals remain as the explicit "I detected misalignment,
* run only that" entry point.
*
* <h2>What this reconciler aligns</h2>
* <ol>
* <li><b>Dependency versions</b> — for each POM, when a
* {@code <dependency>} references another workspace subproject's
* artifact ({@code groupId:artifactId} matches the artifact set
* published by that subproject), the declared version is updated
* to match the subproject's current POM version. Property-based
* versions ({@code ${ike-bom.version}}) update the property;
* direct {@code <version>} tags rewrite in place.</li>
* <li><b>Plugin versions</b> — same matching for {@code <plugin>}
* declarations. Property-based plugin versions are deferred to
* the dependency/property alignment pass above.</li>
* <li><b>Parent versions</b> — when a subproject declares another
* subproject as its parent (via the manifest {@code parent:}
* field), and the parent subproject's version differs from the
* child's {@code <parent><version>}, the child POM (and any
* submodule POMs referencing the same parent GAV) is updated.
* This is distinct from {@link ParentVersionReconciler}, which
* cascades the <em>workspace root POM's</em> parent across
* subprojects.</li>
* </ol>
*
* <p>Reads use Maven 4's {@link PomModel} (Model API, no regex).
* Writes go through {@code PomModel}'s OpenRewrite-LST-backed
* updaters — see {@code feedback_no_sed_on_poms}.
*
* <p>Opt out with {@code -DupdateAlignment=false}.
*/
public class AlignmentReconciler implements Reconciler {
@Override
public String dimension() {
return "Inter-subproject version alignment";
}
@Override
public String optOutFlag() {
return "updateAlignment";
}
@Override
public DriftReport detect(WorkspaceContext ctx) {
Plan plan = computePlan(ctx);
if (plan.totalChanges() == 0) {
return DriftReport.noDrift(dimension());
}
List<String> detail = new ArrayList<>();
for (AlignChange c : plan.changes) {
detail.add(c.subproject + " (" + c.pomRelPath + "): "
+ c.artifact + " " + c.fromVersion + " → " + c.toVersion);
}
String summary = plan.totalChanges()
+ " version(s) drift across "
+ plan.changedSubprojectCount() + " subproject(s)";
String action = "rewrite POM versions to match workspace truth"
+ " on scaffold-publish";
String optOut = "mvn ws:scaffold-publish -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;
}
Plan plan = computePlan(ctx);
if (plan.totalChanges() == 0) {
return;
}
applyPlan(ctx, plan);
ctx.log().info(" " + dimension() + ": updated "
+ plan.totalChanges() + " version(s) across "
+ plan.changedSubprojectCount() + " subproject(s)");
}
// ── Plan computation (read-only, shared by detect and apply) ────
/**
* A single intra-workspace version change applied to one POM.
*
* @param subproject the owning subproject name
* @param pomRelPath path of the POM relative to the subproject root
* @param artifact coordinate label, e.g.
* {@code groupId:artifactId},
* {@code property:ike-bom.version},
* {@code plugin:groupId:artifactId},
* {@code parent:artifactId}
* @param fromVersion the version currently declared
* @param toVersion the version this reconciler will set
*/
private record AlignChange(String subproject, String pomRelPath,
String artifact, String fromVersion,
String toVersion) {}
/**
* The full alignment plan for one reconciliation pass.
* Plans are computed by {@link #computePlan} (no I/O writes) and
* consumed by either {@link #detect} or {@link #applyPlan}.
*/
private record Plan(List<AlignChange> changes) {
int totalChanges() {
return changes.size();
}
int changedSubprojectCount() {
return (int) changes.stream()
.map(c -> c.subproject)
.distinct()
.count();
}
}
/**
* Associates a subproject name with its current POM version.
*/
private record ComponentVersion(String name, String version) {}
/**
* Compute the alignment plan without mutating any POMs. Used by
* {@link #detect} and {@link #apply}.
*/
private Plan computePlan(WorkspaceContext ctx) {
WorkspaceGraph graph = ctx.graph();
File root = ctx.workspaceRoot();
Log log = ctx.log();
Map<String, ComponentVersion> artifactIndex =
buildArtifactIndex(graph, root, log);
List<AlignChange> changes = new ArrayList<>();
// Dependency / plugin / property alignment per subproject.
for (Map.Entry<String, Subproject> entry
: graph.manifest().subprojects().entrySet()) {
String name = entry.getKey();
Subproject subproject = entry.getValue();
File subprojectDir = new File(root, name);
if (!new File(subprojectDir, "pom.xml").exists()) {
continue;
}
List<File> pomFiles;
try {
pomFiles = ReleaseSupport.findPomFiles(subprojectDir);
} catch (MojoException e) {
log.warn(" " + name + ": could not scan POM files — "
+ e.getMessage());
continue;
}
// depends-on entries can declare a version-property hint
// that should track the target subproject's version.
Map<String, String> versionPropertyMap = new LinkedHashMap<>();
for (Dependency dep : subproject.dependsOn()) {
if (dep.versionProperty() != null
&& !dep.versionProperty().isEmpty()) {
Subproject target = graph.manifest().subprojects()
.get(dep.subproject());
if (target != null && target.groupId() != null
&& !target.groupId().isEmpty()) {
versionPropertyMap.put(
dep.subproject(), dep.versionProperty());
}
}
}
for (File pomFile : pomFiles) {
collectDependencyChanges(name, pomFile, artifactIndex,
versionPropertyMap, subprojectDir, root, changes,
log);
collectPluginChanges(name, pomFile, artifactIndex,
subprojectDir, changes, log);
}
}
// Parent alignment: a subproject whose manifest declares
// parent: <other-subproject> tracks that subproject's version.
for (Map.Entry<String, Subproject> entry
: graph.manifest().subprojects().entrySet()) {
String name = entry.getKey();
Subproject subproject = entry.getValue();
String parentSubprojectName = subproject.parent();
if (parentSubprojectName == null) continue;
Subproject parentSubproject = graph.manifest().subprojects()
.get(parentSubprojectName);
if (parentSubproject == null
|| parentSubproject.version() == null) {
continue;
}
File subprojectDir = new File(root, name);
Path pomPath = subprojectDir.toPath().resolve("pom.xml");
if (!Files.exists(pomPath)) continue;
try {
PomModel pom = PomModel.parse(pomPath);
Parent parentInfo = pom.parent();
if (parentInfo == null) continue;
String expectedVersion = parentSubproject.version();
String currentVersion = parentInfo.getVersion();
if (currentVersion == null
|| expectedVersion.equals(currentVersion)) {
continue;
}
changes.add(new AlignChange(
name, "pom.xml",
"parent:" + parentInfo.getArtifactId(),
currentVersion, expectedVersion));
} catch (IOException e) {
log.warn(" " + name + ": could not read parent version — "
+ e.getMessage());
}
}
return new Plan(changes);
}
// ── Plan application (mutates POMs) ─────────────────────────────
private void applyPlan(WorkspaceContext ctx, Plan plan) {
File root = ctx.workspaceRoot();
Log log = ctx.log();
// Group changes by (subproject, pomRelPath) so we re-read each
// file once, apply all of its changes, then write back.
Map<String, List<AlignChange>> byPom = new LinkedHashMap<>();
for (AlignChange c : plan.changes) {
String key = c.subproject + "::" + c.pomRelPath;
byPom.computeIfAbsent(key, k -> new ArrayList<>()).add(c);
}
for (Map.Entry<String, List<AlignChange>> e : byPom.entrySet()) {
List<AlignChange> group = e.getValue();
AlignChange first = group.get(0);
File subprojectDir = new File(root, first.subproject);
Path pomPath = subprojectDir.toPath().resolve(first.pomRelPath);
String content;
try {
content = Files.readString(pomPath, StandardCharsets.UTF_8);
} catch (IOException ex) {
log.warn(" " + first.subproject + ": could not read "
+ first.pomRelPath + " — " + ex.getMessage());
continue;
}
for (AlignChange c : group) {
content = applyOne(content, c, subprojectDir, log);
log.info(" " + c.subproject + " (" + c.pomRelPath
+ "): " + c.artifact + " " + c.fromVersion
+ " → " + c.toVersion);
}
try {
Files.writeString(pomPath, content, StandardCharsets.UTF_8);
} catch (IOException ex) {
throw new MojoException(
"Failed to write " + pomPath + ": "
+ ex.getMessage(), ex);
}
}
}
/**
* Apply a single {@link AlignChange} to in-memory POM text, and
* (for parent updates) cascade the same edit into any submodule
* POMs that share the parent GAV. Returns the updated text for
* the primary POM; the submodule cascade writes directly.
*/
private String applyOne(String content, AlignChange c,
File subprojectDir, Log log) {
String artifact = c.artifact;
if (artifact.startsWith("property:")) {
String propName = artifact.substring("property:".length());
return PomModel.updateProperty(content, propName, c.toVersion);
}
if (artifact.startsWith("plugin:")) {
String coord = artifact.substring("plugin:".length());
int colon = coord.indexOf(':');
if (colon < 0) return content;
String gid = coord.substring(0, colon);
String aid = coord.substring(colon + 1);
return PomModel.updatePluginVersion(content, gid, aid,
c.toVersion);
}
if (artifact.startsWith("parent:")) {
// The parent change was recorded with artifact=parent:<aid>;
// recover parent GA from the parsed POM.
Path pomPath = subprojectDir.toPath().resolve(c.pomRelPath);
try {
PomModel pom = PomModel.parse(pomPath);
Parent p = pom.parent();
if (p == null) return content;
String updated = PomModel.updateParentVersion(content,
p.getGroupId(), p.getArtifactId(), c.toVersion);
cascadeParentIntoSubmodules(subprojectDir, pomPath,
p.getGroupId(), p.getArtifactId(), c.toVersion, log);
return updated;
} catch (IOException e) {
log.warn(" " + c.subproject + ": could not re-parse parent — "
+ e.getMessage());
return content;
}
}
// Default: artifact is groupId:artifactId for a dependency.
int colon = artifact.indexOf(':');
if (colon < 0) return content;
String gid = artifact.substring(0, colon);
String aid = artifact.substring(colon + 1);
return PomModel.updateDependencyVersion(content, gid, aid,
c.toVersion);
}
/**
* Cascade a parent version edit into every submodule POM under a
* subproject that references the same parent {@code groupId:artifactId}.
*/
private static void cascadeParentIntoSubmodules(File subprojectDir,
Path primaryPom,
String parentGid,
String parentAid,
String newVersion,
Log log) {
List<File> subPoms;
try {
subPoms = ReleaseSupport.findPomFiles(subprojectDir);
} catch (MojoException e) {
// Non-fatal: the primary update still landed.
return;
}
for (File subPom : subPoms) {
if (subPom.toPath().equals(primaryPom)) continue;
try {
String subContent = Files.readString(subPom.toPath(),
StandardCharsets.UTF_8);
String updated = PomModel.updateParentVersion(
subContent, parentGid, parentAid, newVersion);
if (!updated.equals(subContent)) {
Files.writeString(subPom.toPath(), updated,
StandardCharsets.UTF_8);
}
} catch (IOException e) {
log.warn(" could not cascade parent into "
+ subPom + " — " + e.getMessage());
}
}
}
// ── POM scanning helpers ────────────────────────────────────────
/**
* Build the {@code groupId:artifactId} → (subproject, version)
* lookup index covering every cloned subproject's published
* artifacts.
*/
private static Map<String, ComponentVersion> buildArtifactIndex(
WorkspaceGraph graph, File root, Log log) {
Map<String, ComponentVersion> index = new LinkedHashMap<>();
for (Map.Entry<String, Subproject> entry
: graph.manifest().subprojects().entrySet()) {
String name = entry.getKey();
File subprojectDir = new File(root, name);
if (!new File(subprojectDir, "pom.xml").exists()) {
continue;
}
String pomVersion;
try {
pomVersion = ReleaseSupport.readPomVersion(
new File(subprojectDir, "pom.xml"));
} catch (MojoException e) {
log.warn(" " + name + ": could not read POM version — "
+ e.getMessage());
continue;
}
Set<PublishedArtifactSet.Artifact> published;
try {
published = PublishedArtifactSet.scan(subprojectDir.toPath());
} catch (IOException e) {
log.warn(" " + name + ": could not scan published artifacts — "
+ e.getMessage());
continue;
}
ComponentVersion cv = new ComponentVersion(name, pomVersion);
for (PublishedArtifactSet.Artifact artifact : published) {
String key = artifact.groupId() + ":" + artifact.artifactId();
index.put(key, cv);
}
}
return index;
}
/**
* Collect dependency and property-driven version changes from one
* POM file. Mirrors {@code alignPomDependencies} from the retired
* {@code WsAlignDraftMojo}.
*/
private static void collectDependencyChanges(String ownerName,
File pomFile,
Map<String, ComponentVersion> artifactIndex,
Map<String, String> versionPropertyMap,
File subprojectDir,
File workspaceRoot,
List<AlignChange> changes,
Log log) {
PomModel pom;
try {
pom = PomModel.parse(pomFile.toPath());
} catch (IOException e) {
log.debug(" " + ownerName + ": skipping " + pomFile.getName()
+ " (empty or unparseable)");
return;
}
for (org.apache.maven.api.model.Dependency dep : pom.allDependencies()) {
String depGroupId = dep.getGroupId();
String depArtifactId = dep.getArtifactId();
String currentVersion = dep.getVersion();
if (depGroupId == null || depArtifactId == null
|| currentVersion == null) {
continue;
}
String key = depGroupId + ":" + depArtifactId;
ComponentVersion target = artifactIndex.get(key);
if (target == null || target.name.equals(ownerName)) {
continue;
}
String relPath = subprojectDir.toPath().relativize(
pomFile.toPath()).toString();
if (currentVersion.startsWith("${")
&& currentVersion.endsWith("}")) {
String propName = currentVersion.substring(2,
currentVersion.length() - 1);
String propValue = pom.properties().get(propName);
if (propValue != null && !propValue.equals(target.version)) {
changes.add(new AlignChange(ownerName, relPath,
"property:" + propName,
propValue, target.version));
}
} else if (!currentVersion.equals(target.version)) {
changes.add(new AlignChange(ownerName, relPath, key,
currentVersion, target.version));
}
}
// depends-on declared version-property hints.
for (Map.Entry<String, String> vpEntry
: versionPropertyMap.entrySet()) {
String targetComponent = vpEntry.getKey();
String versionProperty = vpEntry.getValue();
ComponentVersion cv = findComponentVersion(
targetComponent, artifactIndex, workspaceRoot);
if (cv == null) continue;
String currentValue = pom.properties().get(versionProperty);
if (currentValue != null && !currentValue.equals(cv.version)) {
String relPath = subprojectDir.toPath().relativize(
pomFile.toPath()).toString();
changes.add(new AlignChange(ownerName, relPath,
"property:" + versionProperty,
currentValue, cv.version));
}
}
}
/**
* Collect plugin literal version changes from one POM file.
* Mirrors {@code alignPomPlugins} from the retired
* {@code WsAlignDraftMojo}. Property-based plugin versions
* ({@code ${ike-tooling.version}}) are skipped here — they flow
* through dependency/property alignment instead.
*/
private static void collectPluginChanges(String ownerName,
File pomFile,
Map<String, ComponentVersion> artifactIndex,
File subprojectDir,
List<AlignChange> changes,
Log log) {
PomModel pom;
try {
pom = PomModel.parse(pomFile.toPath());
} catch (IOException e) {
return;
}
for (Plugin plugin : pom.allPlugins()) {
String pluginGroupId = plugin.getGroupId();
String pluginArtifactId = plugin.getArtifactId();
String currentVersion = plugin.getVersion();
if (pluginGroupId == null || pluginArtifactId == null
|| currentVersion == null) {
continue;
}
if (currentVersion.startsWith("${")) {
continue;
}
String key = pluginGroupId + ":" + pluginArtifactId;
ComponentVersion target = artifactIndex.get(key);
if (target == null || target.name().equals(ownerName)) {
continue;
}
if (!currentVersion.equals(target.version())) {
String relPath = subprojectDir.toPath().relativize(
pomFile.toPath()).toString();
changes.add(new AlignChange(ownerName, relPath,
"plugin:" + key,
currentVersion, target.version()));
}
}
}
/**
* Find a subproject's version by scanning its published artifacts
* and looking them up in the artifact index. Matches by subproject
* name (not groupId) so it handles groupId collisions.
*/
private static ComponentVersion findComponentVersion(
String subprojectName,
Map<String, ComponentVersion> artifactIndex,
File root) {
File subprojectDir = new File(root, subprojectName);
if (!new File(subprojectDir, "pom.xml").exists()) {
return null;
}
try {
Set<PublishedArtifactSet.Artifact> published =
PublishedArtifactSet.scan(subprojectDir.toPath());
for (PublishedArtifactSet.Artifact artifact : published) {
String key = artifact.groupId() + ":" + artifact.artifactId();
ComponentVersion cv = artifactIndex.get(key);
if (cv != null && cv.name.equals(subprojectName)) {
return cv;
}
}
} catch (IOException e) {
// Fall through
}
return null;
}
}