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.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Workspace-walking variant of {@code ike:scaffold-draft} (#350).
*
* <p>Iterates the workspace's subprojects (and the workspace root)
* in declaration order, invoking {@code mvn validate ike:scaffold-draft}
* in each via a subprocess {@code mvn} call. The {@code validate} phase
* runs first so {@code ike-parent}'s {@code unpack-scaffold-templates}
* execution populates {@code target/scaffold} before the scaffold goal
* reads it (#449). 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 {
if (!isWorkspaceMode()) {
return executeBareMode();
}
WorkspaceGraph graph = loadGraph();
File root = workspaceRoot();
String goal = publish ? "ike:scaffold-publish" : "ike:scaffold-draft";
String goalLabel = (publish ? WsGoal.SCAFFOLD_PUBLISH
: WsGoal.SCAFFOLD_DRAFT).qualified();
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.
WorkspaceVerifier 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();
// #700: in publish mode the reconcilers above have just rewritten
// each subproject's <version> to its branch-qualified form and
// propagated the qualified BOM into consumers' dependencyManagement
// imports — but the qualified BOM exists only as a rewritten POM on
// disk; it was never mvn install-ed. The per-subproject walk below
// delegates to a *standalone* mvn invocation outside the reactor,
// which cannot resolve a sibling BOM and fails at validate with
// "Non-resolvable import POM". Seed the local repo with the
// qualified BOM/parent artifacts first so each delegation resolves.
if (publish) {
installReconciledBoms(root, targets, report);
}
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(
(publish ? WsGoal.SCAFFOLD_PUBLISH : WsGoal.SCAFFOLD_DRAFT)
.qualified()
+ " saw " + failed + " per-subproject failure(s); "
+ "see logs above.");
}
return new WorkspaceReportSpec(
publish ? WsGoal.SCAFFOLD_PUBLISH : WsGoal.SCAFFOLD_DRAFT,
report.build());
}
// ── Bare mode: single-repo scaffold (no workspace.yaml) ──────────
/**
* Bare mode: scaffold the current single repo by delegating to
* {@code ike:scaffold-draft} (preview) or {@code ike:scaffold-publish}
* (apply). The single-repo case of the same operation — a working set
* of one — with no workspace-level reconcilers or per-subproject walk
* (ike-issues#601). In one repo, {@code ws:scaffold} is exactly
* {@code ike:scaffold}: the console verb forwarding to the per-repo
* build-standards engine.
*
* @return the goal's report
* @throws MojoException if there is no Maven project here, or the
* delegated {@code ike:scaffold-*} fails
*/
private WorkspaceReportSpec executeBareMode() throws MojoException {
// Single-repo scaffold is a working set of one (ike-issues#611) —
// resolve it through the shared resolver, not user.dir directly.
File repo = resolveWorkingSet().members().getFirst().directory().toFile();
if (!new File(repo, "pom.xml").exists()) {
throw new MojoException(
"ws:scaffold: " + repo + " has no pom.xml and no "
+ "workspace.yaml was found — run it in a Maven project, "
+ "or a workspace.");
}
WsGoal goal = publish ? WsGoal.SCAFFOLD_PUBLISH : WsGoal.SCAFFOLD_DRAFT;
String delegate = publish ? "ike:scaffold-publish" : "ike:scaffold-draft";
getLog().info("");
getLog().info(goal.qualified());
getLog().info("══════════════════════════════════════════════════════════════");
getLog().info(" Repo: " + repo.getName());
getLog().info(" Mode: single repo (no workspace.yaml) → " + delegate);
getLog().info("");
ReleaseSupport.exec(repo, getLog(),
bareScaffoldArgs(repo).toArray(new String[0]));
GoalReportBuilder report = new GoalReportBuilder();
report.paragraph("**Repo:** `" + repo + "`");
report.paragraph("Single-repo scaffold "
+ (publish ? "applied" : "previewed") + " via `" + delegate
+ "` — a working set of one, no workspace reconcilers.");
return new WorkspaceReportSpec(goal, report.build());
}
/**
* Build the {@code mvn} argv for the bare-mode single-repo scaffold,
* reusing {@link #buildScaffoldArgs} so the delegated flag set is
* identical to a per-subproject invocation. Package-private so the
* delegation can be asserted without spawning a Maven subprocess.
*
* @param repo the single repo to scaffold
* @return the argv, beginning with the resolved {@code mvn} command
*/
List<String> bareScaffoldArgs(File repo) {
String mvn = WsReleaseDraftMojo.resolveMvnCommand(repo);
String goal = publish ? "ike:scaffold-publish" : "ike:scaffold-draft";
return buildScaffoldArgs(mvn, goal, publish, applyFoundation,
resolveFoundation);
}
/**
* 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 = buildScaffoldArgs(mvn, goal,
publish, applyFoundation, resolveFoundation);
ReleaseSupport.exec(subDir, getLog(), args.toArray(new String[0]));
}
/**
* Build the {@code mvn} argv for one per-subproject scaffold
* invocation. Extracted as a pure helper so the #440 and #449
* regressions can assert the exact flag set without spawning a
* Maven subprocess.
*
* <ul>
* <li>{@code validate} runs before {@code goal} so
* ike-parent's {@code unpack-scaffold-templates} (bound to
* {@code validate}) populates {@code target/scaffold}
* (#449).</li>
* <li>{@code -Dike.scaffold.skip-parent=true} is always set so
* the workspace's {@code ParentVersionReconciler} stays
* authoritative on {@code <parent>} (#418), and the inner
* {@code ike:scaffold-publish} dry-run never compares the
* live POM against the stale {@code foundation:} pin baked
* in the scaffold zip (#440).</li>
* <li>{@code -Dike.scaffold.apply-foundation=true} is forwarded
* only when both {@code publish} and {@code applyFoundation}
* are set; {@code -Dike.scaffold.resolve-foundation=true}
* additionally requires {@code resolveFoundation}.</li>
* </ul>
*
* @param mvn wrapper or {@code mvn} command resolved
* for this subproject
* @param goal the ike-prefixed scaffold goal
* ({@code ike:scaffold-draft} or
* {@code ike:scaffold-publish})
* @param publish whether the workspace mojo is in
* publish mode
* @param applyFoundation whether the foundation cascade should
* be applied (not just previewed)
* @param resolveFoundation whether to forward the foundation-
* resolution flag
* @return mutable {@link List} of argv strings, beginning with
* {@code mvn}
*/
static List<String> buildScaffoldArgs(String mvn, String goal,
boolean publish,
boolean applyFoundation,
boolean resolveFoundation) {
List<String> args = new ArrayList<>();
args.add(mvn);
args.add("validate");
args.add(goal);
args.add("-B");
args.add("-Dike.scaffold.skip-parent=true");
if (publish && applyFoundation) {
args.add("-Dike.scaffold.apply-foundation=true");
if (resolveFoundation) {
args.add("-Dike.scaffold.resolve-foundation=true");
}
}
return args;
}
/** Matches a project-level {@code <packaging>pom</packaging>}. */
private static final Pattern POM_PACKAGING =
Pattern.compile("<packaging>\\s*pom\\s*</packaging>");
/**
* Install the workspace's BOM/parent-only subprojects into the local
* Maven repository so the per-subproject scaffold delegations that
* follow can resolve them (IKE-Network/ike-issues#700).
*
* <p>Only {@code <packaging>pom</packaging>} subprojects are installed:
* BOMs, parent POMs, and aggregators are the artifacts that downstream
* subprojects import (via {@code <scope>import</scope>}) or inherit
* (via {@code <parent>}), and after feature-version qualification the
* only copy of the qualified artifact is the rewritten POM on disk. A
* {@code mvn install -N} is cheap for a POM project — no compilation,
* just the POM copied into {@code ~/.m2}.
*
* <p>{@code targets} is already in topological order, so a BOM whose
* own {@code <parent>} is itself a workspace member is installed after
* that parent. Each install uses the subproject's own Maven wrapper —
* by this point {@link network.ike.plugin.ws.reconcile.MavenWrapperReconciler}
* has already pinned every wrapper to the workspace Maven version (#701).
*
* @param root the workspace root directory
* @param targets the cloned subproject names, in topological order
* @param report the goal report being accumulated
* @throws MojoException if a BOM install fails
*/
private void installReconciledBoms(File root, List<String> targets,
GoalReportBuilder report) throws MojoException {
List<String> installed = new ArrayList<>();
for (String name : targets) {
File subDir = new File(root, name);
if (!isPomPackaging(new File(subDir, "pom.xml"))) {
continue;
}
getLog().info("");
getLog().info(" Installing reconciled BOM/parent: " + name);
String mvn = WsReleaseDraftMojo.resolveMvnCommand(subDir);
ReleaseSupport.exec(subDir, getLog(),
mvn, "install", "-N", "-B");
installed.add(name);
}
if (!installed.isEmpty()) {
report.section("Reconciled BOMs installed")
.paragraph("Seeded the local repository with "
+ installed.size() + " branch-qualified "
+ "BOM/parent artifact(s) so each standalone "
+ "subproject scaffold can resolve them "
+ "(IKE-Network/ike-issues#700): `"
+ String.join("`, `", installed) + "`.");
}
}
/**
* Report whether a POM declares {@code <packaging>pom</packaging>}.
* Package-private and pure so the BOM-detection predicate can be
* unit-tested without spawning Maven.
*
* @param pom the POM file to inspect
* @return {@code true} when the file exists and declares pom packaging;
* {@code false} when absent, jar-packaged, or unreadable
*/
static boolean isPomPackaging(File pom) {
if (!pom.exists()) {
return false;
}
try {
String content =
Files.readString(pom.toPath(), StandardCharsets.UTF_8);
return POM_PACKAGING.matcher(content).find();
} catch (IOException e) {
return false;
}
}
/**
* 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("`" + WsGoal.SCAFFOLD_PUBLISH.qualified()
+ "` 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 " + WsGoal.SCAFFOLD_PUBLISH.qualified()
+ " -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());
}
}
}