ReactorSubprojectsReconciler.java
package network.ike.plugin.ws.reconcile;
import network.ike.plugin.ws.ReactorPom;
import network.ike.plugin.ws.ReactorPom.ProfileEntry;
import network.ike.plugin.ws.WsGoal;
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.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Reconciler that keeps the workspace <em>reactor</em> POM's subproject
* membership in lockstep with {@code workspace.yaml}, and migrates the
* legacy {@code with-<name>} file-activated profile pattern to
* unconditional top-level {@code <subprojects>} entries
* (IKE-Network/ike-issues#696, completing #460).
*
* <p>Before this reconciler, the only path that added a member to the
* reactor POM was {@code ws:add}, one profile at a time. A subproject
* that entered {@code workspace.yaml} by any other route — feature-finish
* YAML reconciliation, bulk import, a hand edit — silently dropped out of
* the reactor, and IntelliJ (which imports the workspace root) never saw
* it. There was no convergence step to catch the drift.
*
* <p>Each {@code ws:scaffold-{draft,publish}} run now converges the
* reactor POM to:
* <ol>
* <li>a top-level {@code <subprojects>} block listing exactly the
* {@code workspace.yaml} keys, in declaration order — adding the
* missing, removing the stale; and</li>
* <li>zero {@code with-*} subproject profiles — every one is retired,
* its member already declared top-level.</li>
* </ol>
*
* <p>Top-level declarations are safe even for not-yet-cloned subprojects:
* {@code ike-workspace-extension}'s {@code SubprojectPruneTransformer}
* prunes entries whose directory is absent at model-read time (#460).
*
* <p>Pure normalization — no version bumps, no clones. {@code detect}
* reports the convergence; {@code apply} performs it. Idempotent: a second
* run is a no-op. Opt out with {@code -DupdateReactor=false}.
*
* @see ReactorPom
*/
public class ReactorSubprojectsReconciler implements Reconciler {
/** Creates the reconciler. */
public ReactorSubprojectsReconciler() {}
@Override
public String dimension() {
return "Reactor subprojects";
}
@Override
public String optOutFlag() {
return "updateReactor";
}
@Override
public DriftReport detect(WorkspaceContext ctx) {
Path pom = ctx.workspaceRoot().toPath().resolve("pom.xml");
if (!Files.exists(pom)) {
return DriftReport.noDrift(dimension());
}
String content;
try {
content = Files.readString(pom, StandardCharsets.UTF_8);
} catch (IOException e) {
ctx.log().warn(" Reactor subprojects: could not read "
+ "pom.xml: " + e.getMessage());
return DriftReport.noDrift(dimension());
}
List<String> yamlKeys = subprojectKeys(ctx);
String converged = converge(content, yamlKeys);
if (converged.equals(content)) {
return DriftReport.noDrift(dimension());
}
List<String> detail = describe(content, yamlKeys);
String summary = detail.size() + " reactor-membership change(s)";
String action = "rewrite the reactor POM's <subprojects> and retire "
+ "with-* profiles 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;
}
Path pom = ctx.workspaceRoot().toPath().resolve("pom.xml");
if (!Files.exists(pom)) {
return;
}
try {
String content = Files.readString(pom, StandardCharsets.UTF_8);
String converged = converge(content, subprojectKeys(ctx));
if (converged.equals(content)) {
return;
}
Files.writeString(pom, converged, StandardCharsets.UTF_8);
ctx.log().info(" " + dimension() + ": reactor POM converged to "
+ "workspace.yaml membership");
} catch (IOException e) {
ctx.log().warn(" Reactor subprojects: could not update "
+ "pom.xml: " + e.getMessage());
}
}
// ── Core transform (shared by detect and apply) ─────────────────
/**
* Converge a reactor POM to the target membership: declare exactly
* {@code yamlKeys} top-level, then retire every legacy subproject
* profile. Pure — returns the input unchanged when already converged.
*
* @param pomContent the reactor POM text
* @param yamlKeys the {@code workspace.yaml} subproject keys, in order
* @return the converged POM text
*/
static String converge(String pomContent, List<String> yamlKeys) {
String result = ReactorPom.setSubprojects(pomContent, yamlKeys);
for (ProfileEntry profile : ReactorPom.listSubprojectProfiles(result)) {
if (profile.id() != null) {
result = ReactorPom.removeProfile(result, profile.id());
}
}
return result;
}
/**
* Build the human-readable drift detail for {@code detect}: which
* members migrate from a profile, which are newly added, which stale
* entries are dropped, and which profiles are retired.
*/
private static List<String> describe(String pomContent,
List<String> yamlKeys) {
List<String> top = ReactorPom.listSubprojects(pomContent);
List<ProfileEntry> profiles =
ReactorPom.listSubprojectProfiles(pomContent);
Set<String> topSet = new LinkedHashSet<>(top);
Set<String> profileMembers = new LinkedHashSet<>();
for (ProfileEntry p : profiles) {
profileMembers.addAll(p.subprojects());
}
Set<String> yamlSet = new LinkedHashSet<>(yamlKeys);
List<String> detail = new ArrayList<>();
for (String key : yamlKeys) {
if (topSet.contains(key)) {
continue;
}
if (profileMembers.contains(key)) {
detail.add("migrate profile member → <subprojects>: " + key);
} else {
detail.add("add subproject: " + key);
}
}
for (String s : top) {
if (!yamlSet.contains(s)) {
detail.add("remove stale subproject: " + s);
}
}
for (ProfileEntry p : profiles) {
detail.add("retire profile: "
+ (p.id() != null ? p.id() : p.subprojects()));
}
return detail;
}
/**
* The {@code workspace.yaml} subproject keys in declaration order.
*/
private static List<String> subprojectKeys(WorkspaceContext ctx) {
return new ArrayList<>(
ctx.graph().manifest().subprojects().keySet());
}
}