WsScaffoldDraftMojo.java
package network.ike.plugin.ws;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.ws.reconcile.DriftReport;
import network.ike.plugin.ws.reconcile.Reconciler;
import network.ike.plugin.ws.reconcile.ReconcilerOptions;
import network.ike.plugin.ws.reconcile.ReconcilerRegistry;
import network.ike.plugin.ws.reconcile.WorkspaceContext;
import network.ike.plugin.ws.verify.WorkspaceVerifier;
import network.ike.workspace.Subproject;
import network.ike.workspace.WorkspaceGraph;
import org.apache.maven.api.ArtifactCoordinates;
import org.apache.maven.api.Session;
import org.apache.maven.api.Version;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;
import org.apache.maven.api.services.VersionRangeResolver;
import org.apache.maven.api.services.VersionRangeResolverResult;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Workspace-walking variant of {@code ike:scaffold-draft} (#350).
*
* <p>Iterates the workspace's subprojects (and the workspace root)
* in declaration order, invoking {@code ike:scaffold-draft} in each
* via a subprocess {@code mvn} call. Each per-subproject invocation
* is independent — output is streamed straight through, drift
* reports surface inline.
*
* <p>Mirrors the {@code ws:release-publish} → per-subproject
* {@code ike:release-publish} cascade pattern. Read-only: no POM
* mutation, no lockfile writes; use {@link WsScaffoldPublishMojo}
* to apply.
*
* <p>Usage:
* <pre>{@code
* mvn ws:scaffold-draft # report drift across every subproject
* mvn ws:scaffold-publish # apply (foundation drift opt-in)
* }</pre>
*
* @see WsScaffoldPublishMojo
*/
@Mojo(name = "scaffold-draft", projectRequired = false, aggregator = true)
public class WsScaffoldDraftMojo extends AbstractWorkspaceMojo {
/**
* When {@code true}, the subprocess invocation is
* {@code ike:scaffold-publish}; when {@code false} (default for
* the draft variant), it is {@code ike:scaffold-draft}. Set by
* {@link WsScaffoldPublishMojo} for the apply path.
*/
@Parameter(property = "publish", defaultValue = "false")
boolean publish;
/**
* Forwarded to per-subproject {@code ike:scaffold-publish}: opt
* in to apply the foundation drift identified by #345's checker.
* Only consulted when {@link #publish} is {@code true}.
*/
@Parameter(property = "ike.scaffold.apply-foundation",
defaultValue = "false")
boolean applyFoundation;
/**
* Forwarded to per-subproject {@code ike:scaffold-publish}: resolve
* the latest released foundation versions instead of the snapshot
* baked into the scaffold zip, so a stale subproject jumps straight
* to current. The escape hatch for the scaffold bootstrap loop.
* Only consulted when {@link #publish} and {@link #applyFoundation}
* are both {@code true}.
*/
@Parameter(property = "ike.scaffold.resolve-foundation",
defaultValue = "false")
boolean resolveFoundation;
/** Creates this goal instance. */
public WsScaffoldDraftMojo() {}
@Override
protected WorkspaceReportSpec runGoal() throws MojoException {
WorkspaceGraph graph = loadGraph();
File root = workspaceRoot();
String goal = publish ? "ike:scaffold-publish" : "ike:scaffold-draft";
String goalLabel = publish ? "ws:scaffold-publish"
: "ws:scaffold-draft";
getLog().info("");
getLog().info(goalLabel);
getLog().info("══════════════════════════════════════════════════════════════");
// Accumulate the markdown report alongside the console output
// (IKE-Network/ike-issues#407) so the goal writes its
// ws꞉scaffold-{draft,publish}.md like every other goal.
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph("**Workspace:** `" + root + "`");
// Workspace-wide verification (formerly ws:verify, retired in
// #393). Runs in both draft and publish modes — it's read-only,
// so the call shape is identical. Parent alignment was dropped
// from this extraction because ParentVersionReconciler.detect()
// (below) already covers it.
var verifier = new WorkspaceVerifier(getLog(), graph, root,
resolveManifest(), false /* checkConvergence */,
isWorkspaceMode());
verifier.runAllChecks();
report.paragraph("Workspace verification ran — see the console "
+ "output for the full check list.");
// Workspace-level reconcilers run next (#393). Each owns one
// dimension of workspace state (denormalized YAML fields,
// parent version, alignment, etc.) and is reported (draft) or
// applied (publish) before the per-subproject ike:scaffold
// delegation runs.
report.section(publish
? "Workspace reconcilers applied"
: "Workspace reconciler drift");
runWorkspaceReconcilers(graph, root, report);
// #417: foundation currency — discover whether a newer parent
// has been released and offer a deterministic upgrade command.
// Draft only; publish is already applying.
if (!publish) {
reportLatestParent(root, report);
}
// Walk each subproject in topological order, then the
// workspace root. Topological order isn't strictly needed
// for scaffold (each project's drift is independent), but
// keeps output predictable.
List<String> targets = new ArrayList<>();
for (String name : graph.topologicalSort()) {
File subDir = new File(root, name);
if (!new File(subDir, "pom.xml").exists()) {
getLog().debug(" " + name + ": not cloned — skipping");
continue;
}
targets.add(name);
}
// Workspace root last (mirrors ws:release-publish ordering).
boolean walkRoot = new File(root, "pom.xml").exists();
report.section("Subprojects walked");
int processed = 0;
int failed = 0;
for (String name : targets) {
File subDir = new File(root, name);
getLog().info("");
getLog().info("── " + name + " ".repeat(Math.max(1, 60 - name.length())) + "──");
try {
runScaffoldInSubproject(subDir, goal);
report.bullet("✓ " + name);
processed++;
} catch (MojoException e) {
getLog().error(" ✗ " + name + ": " + e.getMessage());
report.bullet("✗ " + name + " — " + e.getMessage());
failed++;
}
}
if (walkRoot) {
getLog().info("");
getLog().info("── (workspace root)" + " ".repeat(43) + "──");
try {
runScaffoldInSubproject(root, goal);
report.bullet("✓ (workspace root)");
processed++;
} catch (MojoException e) {
getLog().error(" ✗ workspace root: " + e.getMessage());
report.bullet("✗ (workspace root) — " + e.getMessage());
failed++;
}
}
getLog().info("");
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info(" Walked " + processed + " project(s)"
+ (failed > 0 ? "; " + failed + " failed" : ""));
report.paragraph(processed + " project(s) walked"
+ (failed > 0 ? "; " + failed + " failed" : "") + ".");
report.paragraph("Per-subproject scaffold detail is in each "
+ "subproject's own `ike꞉scaffold-"
+ (publish ? "publish" : "draft") + ".md` report.");
// ws:scaffold-publish edits POMs and scaffold files in place
// without committing — surface the resulting uncommitted state
// so the operator can see what to review (#431).
if (publish) {
reportUncommittedState(root, targets, walkRoot, report);
}
if (failed > 0) {
throw new MojoException(
"ws:" + (publish ? "scaffold-publish" : "scaffold-draft")
+ " saw " + failed + " per-subproject failure(s); "
+ "see logs above.");
}
return new WorkspaceReportSpec(
publish ? WsGoal.SCAFFOLD_PUBLISH : WsGoal.SCAFFOLD_DRAFT,
report.build());
}
/**
* Run {@code mvn <goal>} in the given subproject directory. When
* {@code goal} is {@code ike:scaffold-publish} and the
* {@link #applyFoundation} flag is set, forwards
* {@code -Dike.scaffold.apply-foundation=true} — plus
* {@code -Dike.scaffold.skip-parent=true} always (the workspace's
* {@code ParentVersionReconciler} owns the {@code <parent>}
* cascade, #418), and {@code -Dike.scaffold.resolve-foundation=true}
* when {@link #resolveFoundation} is set.
*
* @param subDir the subproject directory
* @param goal the ike goal to invoke (draft or publish)
*/
private void runScaffoldInSubproject(File subDir, String goal)
throws MojoException {
String mvn = WsReleaseDraftMojo.resolveMvnCommand(subDir);
List<String> args = new ArrayList<>();
args.add(mvn);
args.add(goal);
args.add("-B");
if (publish && applyFoundation) {
args.add("-Dike.scaffold.apply-foundation=true");
// The workspace ParentVersionReconciler owns <parent>; the
// per-subproject foundation-apply must not overwrite its
// cascade — apply the property pins only (#418).
args.add("-Dike.scaffold.skip-parent=true");
if (resolveFoundation) {
args.add("-Dike.scaffold.resolve-foundation=true");
}
}
ReleaseSupport.exec(subDir, getLog(), args.toArray(new String[0]));
}
/**
* Append an "Uncommitted changes" section listing the files
* {@code ws:scaffold-publish} left modified and uncommitted, per
* repo (IKE-Network/ike-issues#431).
*
* <p>The goal edits POMs and scaffold files in place and does not
* commit, so this section is the operator's checklist of what to
* review and commit, and in which repo.
*
* @param root the workspace root directory
* @param targets the subproject names that were walked
* @param walkRoot whether the workspace root itself was walked
* @param report the goal report being accumulated
*/
private void reportUncommittedState(File root, List<String> targets,
boolean walkRoot, GoalReportBuilder report) {
report.section("Uncommitted changes");
StringBuilder body = new StringBuilder();
boolean any = false;
for (String name : targets) {
any |= appendRepoStatus(name, new File(root, name), body);
}
if (walkRoot) {
any |= appendRepoStatus("(workspace root)", root, body);
}
if (any) {
report.paragraph("`ws:scaffold-publish` edits files in place "
+ "and does not commit. Review and commit per repo:");
report.raw(body.toString());
} else {
report.paragraph("No files were modified.");
}
}
/**
* Append one repo's {@code git status --porcelain} to {@code body}
* as a Markdown bullet listing its changed files.
*
* @param label the repo label shown in the report
* @param dir the repo directory
* @param body the Markdown buffer to append to
* @return {@code true} if the repo had uncommitted changes
*/
private boolean appendRepoStatus(String label, File dir,
StringBuilder body) {
if (!new File(dir, ".git").isDirectory()) {
return false;
}
String status;
try {
status = ReleaseSupport.execCapture(dir,
"git", "status", "--porcelain");
} catch (RuntimeException e) {
getLog().debug(" " + label + ": git status failed — "
+ e.getMessage());
return false;
}
if (status == null || status.isBlank()) {
return false;
}
List<String> lines = status.strip().lines().toList();
body.append("- **").append(label).append("** — ")
.append(lines.size()).append(" file(s)\n");
for (String line : lines) {
body.append(" - `").append(line.strip()).append("`\n");
}
return true;
}
/**
* Run the workspace-level {@link Reconciler}s registered in
* {@link ReconcilerRegistry}. In draft mode each reconciler's
* {@link Reconciler#detect} result is printed; in publish mode
* {@link Reconciler#apply} is invoked (honoring opt-out flags).
*
* <p>Workspace-level reconcilers act on {@code workspace.yaml}
* and other cross-subproject state, complementing the
* per-subproject {@code ike:scaffold-*} pass that follows.
*/
private void runWorkspaceReconcilers(WorkspaceGraph graph, File root,
GoalReportBuilder report) throws MojoException {
WorkspaceContext ctx = new WorkspaceContext(
root, resolveManifest(), graph,
readReconcilerOptions(), getLog());
for (Reconciler reconciler : ReconcilerRegistry.all()) {
if (publish) {
if (ctx.options().isOptedOut(reconciler.optOutFlag())) {
// apply() self-checks the opt-out and no-ops; the
// report says so rather than claiming a change.
reconciler.apply(ctx);
report.raw("- ⊘ **" + reconciler.dimension()
+ "** — skipped (opted out via -D"
+ reconciler.optOutFlag() + "=false)\n");
} else {
// detect() is read-only and the workspace is
// unchanged until apply() runs, so the captured
// report describes exactly what apply() does —
// including the per-subproject before → after
// detail (IKE-Network/ike-issues#431).
DriftReport applied = reconciler.detect(ctx);
reconciler.apply(ctx);
report.raw(applied.toAppliedMarkdown());
}
} else {
DriftReport drift = reconciler.detect(ctx);
printDriftReport(drift);
report.raw(drift.toMarkdown());
}
}
}
/**
* Collect all Maven system properties into a {@link ReconcilerOptions}
* bag so reconcilers can query their opt-out and pin flags
* (e.g., {@code -DupdateFields=false}, {@code -DparentVersion=55}).
*/
private static ReconcilerOptions readReconcilerOptions() {
Map<String, String> flags = new HashMap<>();
for (String name : System.getProperties().stringPropertyNames()) {
flags.put(name, System.getProperty(name));
}
return new ReconcilerOptions(flags);
}
/**
* Discover whether a newer parent than the workspace root POM's
* {@code <parent>} version has been released, and — when one has —
* print and report a deterministic copy-paste upgrade command
* (IKE-Network/ike-issues#417).
*
* <p>The command pins {@code -DparentVersion=X}, the existing pin
* flag of {@link network.ike.plugin.ws.reconcile.ParentVersionReconciler}:
* the next {@code ws:scaffold-publish} updates the workspace root
* POM to {@code X} and cascades it to every subproject. Discovery
* is best-effort — an unreachable remote is logged at debug and
* the draft proceeds.
*
* @param root the workspace root directory
* @param report the goal report being accumulated
*/
private void reportLatestParent(File root, GoalReportBuilder report) {
PomParentSupport.ParentInfo rootParent;
try {
rootParent = PomParentSupport.readParent(
root.toPath().resolve("pom.xml"));
} catch (IOException e) {
getLog().debug("Foundation currency: could not read the "
+ "workspace root POM parent: " + e.getMessage());
return;
}
if (rootParent == null) {
return;
}
String latest = latestReleasedVersion(rootParent.groupId(),
rootParent.artifactId(), rootParent.version());
if (latest == null) {
return;
}
String command = "mvn ws:scaffold-publish -DparentVersion=" + latest;
getLog().info("");
getLog().info("Foundation currency:");
getLog().info(" Latest released " + rootParent.artifactId()
+ ": " + latest + " (workspace root pins "
+ rootParent.version() + ").");
getLog().info(" To upgrade the whole workspace, run:");
getLog().info(" " + command);
report.section("Foundation upgrade available")
.paragraph("A newer `" + rootParent.artifactId() + "` (`"
+ latest + "`) is released; the workspace root "
+ "pins `" + rootParent.version() + "`. To "
+ "upgrade the whole workspace, run:")
.codeBlock("", command);
}
/**
* Resolve the highest released (non-SNAPSHOT) version of a Maven
* coordinate strictly newer than {@code current}, via the Maven 4
* {@link VersionRangeResolver}.
*
* @param groupId the coordinate groupId
* @param artifactId the coordinate artifactId
* @param current the currently-pinned version (exclusive lower
* bound of the range)
* @return the highest released version newer than {@code current},
* or {@code null} when none exists or resolution fails
*/
private String latestReleasedVersion(String groupId, String artifactId,
String current) {
try {
Session s = getSession();
VersionRangeResolver resolver =
s.getService(VersionRangeResolver.class);
ArtifactCoordinates coords = s.createArtifactCoordinates(
groupId + ":" + artifactId + ":(" + current + ",)");
VersionRangeResolverResult result = resolver.resolve(s, coords);
String best = null;
for (Version v : result.getVersions()) {
String str = v.toString();
if (!str.endsWith("-SNAPSHOT")) {
best = str; // resolver returns ascending — last wins
}
}
return best;
} catch (Exception e) {
getLog().debug("Foundation currency: could not resolve the "
+ "latest " + groupId + ":" + artifactId + " — "
+ e.getMessage());
return null;
}
}
/**
* Render a {@link DriftReport} for {@code scaffold-draft} output
* with the copy-paste opt-out command inline.
*/
private void printDriftReport(DriftReport report) {
getLog().info("");
if (!report.hasDrift()) {
getLog().info(" ✓ " + report.dimension());
return;
}
getLog().info(" ⚠ " + report.dimension());
if (!report.summary().isEmpty()) {
getLog().info(" " + report.summary());
}
for (String line : report.detailLines()) {
getLog().info(" " + line);
}
if (!report.defaultAction().isEmpty()) {
getLog().info(" Default: " + report.defaultAction());
}
if (!report.optOutCommand().isEmpty()) {
getLog().info(" Opt out: " + report.optOutCommand());
}
}
}