PreflightCondition.java
package network.ike.plugin.ws.preflight;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.SnapshotScanner;
import network.ike.plugin.ws.WsGoal;
import network.ike.plugin.ws.vcs.VcsOperations;
import network.ike.plugin.ws.vcs.VcsState;
import network.ike.workspace.Dependency;
import network.ike.workspace.Subproject;
import network.ike.workspace.WorkspaceGraph;
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.Optional;
import java.util.Set;
/**
* Closed vocabulary of preflight checks that {@code ws:*} goals can
* require before they mutate workspace state. Each entry declares a
* human-readable description and a {@link #check(PreflightContext)}
* implementation that returns {@link Optional#empty()} on success or a
* remediation message on failure.
*
* <p>Drafts and publishes invoke the same {@code PreflightCondition}
* sequence via {@link Preflight}; whether failure is a warning (draft)
* or a hard error (publish) is decided at the call site via
* {@link PreflightResult#requirePassed(WsGoal)} vs
* {@link PreflightResult#warnIfFailed(Log, WsGoal)}.
*
* <p>New conditions are added here as goals adopt the contract from
* issue #154. Each new entry must stay self-contained: it does not
* depend on the mojo instance, only on the shared {@link PreflightContext}.
*/
public enum PreflightCondition {
/**
* Every subproject working tree (and the workspace root itself, if
* it is a git repo) must have no uncommitted changes. Any draft or
* publish goal that creates branches, edits POMs, or otherwise
* mutates files requires this.
*/
WORKING_TREE_CLEAN("All subproject working trees are clean") {
@Override
public Optional<String> check(PreflightContext ctx) {
File root = ctx.workspaceRoot();
List<String> uncommitted = new ArrayList<>();
if (new File(root, ".git").exists()
&& !gitStatus(root).isEmpty()) {
uncommitted.add(WORKSPACE_ROOT_NAME);
}
for (String name : ctx.subprojects()) {
File dir = new File(root, name);
if (!new File(dir, ".git").exists()) continue;
if (!gitStatus(dir).isEmpty()) {
uncommitted.add(name);
}
}
if (uncommitted.isEmpty()) return Optional.empty();
StringBuilder sb = new StringBuilder();
sb.append(uncommitted.size())
.append(" subproject(s) have uncommitted changes:\n");
boolean anyGhPagesLeak = false;
for (String name : uncommitted) {
File dir = WORKSPACE_ROOT_NAME.equals(name)
? root : new File(root, name);
String status = gitStatus(dir);
if (containsGhPagesLeakPattern(status)) {
anyGhPagesLeak = true;
}
String files = status.lines()
.map(l -> " " + l.strip())
.reduce((a, b) -> a + "\n" + b)
.orElse("");
sb.append(" • ").append(name).append(":\n")
.append(files).append("\n");
}
sb.append(" To resolve:\n");
sb.append(" mvn " + WsGoal.COMMIT_PUBLISH.qualified()
+ " -Dmessage=\"<your message>\"\n");
sb.append(" Or stash changes in each affected subproject.");
if (anyGhPagesLeak) {
sb.append("\n");
sb.append(" ⚠ Some entries above match gh-pages-style site\n");
sb.append(" output (paths like <artifactId>/<artifactId>/\n");
sb.append(" or examples/<name>/, containing bom.json,\n");
sb.append(" built-with.html, dependencies.html, etc.).\n");
sb.append(" These do NOT belong in main — the canonical\n");
sb.append(" published content lives on each repo's\n");
sb.append(" gh-pages branch. Delete them and add\n");
sb.append(" .gitignore entries to prevent recurrence.\n");
sb.append(" NEVER use `git add -A` or `git add .` to\n");
sb.append(" stage cascade bump commits — these sweep\n");
sb.append(" such leaks into main. Use explicit paths:\n");
sb.append(" `git add pom.xml`. ike-issues#358.");
}
return Optional.of(sb.toString());
}
},
/**
* No {@code <properties>} entry in any subproject root POM may hold a
* value ending in {@code -SNAPSHOT}. Maven 4's consumer POM flattener
* resolves properties and promotes {@code <pluginManagement>} into
* {@code <plugins>} when writing the released artifact — a SNAPSHOT
* property value would then be baked in as a literal and break
* downstream consumers (e.g. {@code <ike-tooling.version>112-SNAPSHOT}
* leaking into released {@code ike-parent-105.pom}). This check
* forces the release operator to bump the property to a released
* version before cutting the release.
*/
NO_SNAPSHOT_PROPERTIES("No subproject root POM carries -SNAPSHOT property values") {
@Override
public Optional<String> check(PreflightContext ctx) {
File root = ctx.workspaceRoot();
List<SnapshotScanner.Violation> all = new ArrayList<>();
for (String name : ctx.subprojects()) {
File pom = new File(new File(root, name), "pom.xml");
if (!pom.isFile()) continue;
for (SnapshotScanner.Violation violation
: SnapshotScanner.scanSourceProperties(pom)) {
// Release-set-aware exemption (ike-issues#981): a
// property tracking a member that releases in this
// mission is substituted with the member's released
// version by the loop's catch-up alignment before
// its consumer releases — it never leaks.
if (!resolvedByReleaseMission(ctx, name, violation)) {
all.add(violation);
}
}
}
if (all.isEmpty()) return Optional.empty();
return Optional.of(SnapshotScanner.formatViolations(all, root,
all.size() + " SNAPSHOT property value(s) would leak into"
+ " released POMs:",
" These values are substituted by Maven 4's consumer POM\n"
+ " flattener and baked into released artifacts. Bump each\n"
+ " property to a released (non-SNAPSHOT) version before\n"
+ " re-running the release."));
}
},
/**
* No releasing member's POMs — every module, profiles included —
* may carry a literal {@code -SNAPSHOT} version on a dependency,
* parent, or plugin reference the mission does not itself resolve
* (ike-issues#1022).
*
* <p>The property channel is guarded by
* {@link #NO_SNAPSHOT_PROPERTIES}; this is the literal channel.
* A released POM naming an external SNAPSHOT is not
* repository-true: it builds only where a local repository happens
* to cache the snapshot — which is how komet-desktop shipped two
* releases pinning {@code dev.ikm.jpms:rocksdbjni-jpms
* 10.4.2-r1-SNAPSHOT}, an artifact that never released anywhere
* (ike-issues#1021), while warm caches kept every machine green
* and the cold CI agent alone resolved honestly.
*
* <p>Exempt, because the mission resolves them before anything
* deploys: references to artifacts this mission's release plan
* de-qualifies (retargeted by the version pass), and references to
* coordinates produced inside the same repository (the member's
* own version pass moves those). Bystanders' POMs are not scanned
* — they do not deploy this mission. Without a release set the
* condition is inert: development trees legitimately reference
* snapshots.
*/
NO_EXTERNAL_SNAPSHOT_DEPENDENCIES(
"No releasing member references external -SNAPSHOT versions") {
@Override
public Optional<String> check(PreflightContext ctx) {
if (ctx.releaseSet() == null || ctx.releaseSet().isEmpty()) {
return Optional.empty();
}
File root = ctx.workspaceRoot();
List<SnapshotScanner.Violation> external = new ArrayList<>();
for (String name : ctx.subprojects()) {
if (!ctx.releaseSet().contains(name)) continue;
File memberDir = new File(root, name);
if (!new File(memberDir, "pom.xml").isFile()) continue;
List<File> poms = memberPoms(memberDir);
Set<String> ownKeys = ownCoordinateKeys(poms, memberDir);
for (SnapshotScanner.Violation violation
: SnapshotScanner.scanForSnapshotVersions(poms)) {
if (!cycleResolvesReference(ctx, ownKeys, violation)) {
external.add(violation);
}
}
}
if (external.isEmpty()) return Optional.empty();
return Optional.of(SnapshotScanner.formatViolations(external,
root,
external.size() + " external SNAPSHOT reference(s) would"
+ " ship in released POMs:",
" Each site names a version this mission neither releases\n"
+ " nor retargets, so the deployed POM would reference a\n"
+ " SNAPSHOT no repository is required to have. Pin each\n"
+ " site to a released version (or remove the version and\n"
+ " let managed/bound resolution supply it) before\n"
+ " re-running the release. ike-issues#1022."));
}
},
/**
* No {@code .mvn/jvm.config} file in the workspace root or any
* subproject may contain a line starting with {@code #}.
*
* <p>Maven parses {@code .mvn/jvm.config} as raw JVM arguments —
* one token per line, with no comment syntax. A {@code #} at
* column 0 is passed to the JVM launcher as a main-class name and
* IntelliJ surfaces it as
* {@code Error: Could not find or load main class #}. The fix is
* to delete the offending line; comments belong in
* {@code .mvn/jvm.config.notes} or similar adjacent files.
*
* <p>This is the gate referenced in ike-issues#217. The check fires
* before the bad file can propagate to git or Syncthing — Maven's
* own {@code validate} phase can't catch this in the project that
* contains the bad file because the JVM dies before plugin code
* runs.
*/
JVM_CONFIG_NO_HASH_COMMENTS(
"No .mvn/jvm.config file contains a # comment line") {
@Override
public Optional<String> check(PreflightContext ctx) {
File root = ctx.workspaceRoot();
List<String> violations = new ArrayList<>();
collectJvmConfigViolations(root, WORKSPACE_ROOT_NAME, violations);
for (String name : ctx.subprojects()) {
collectJvmConfigViolations(new File(root, name), name,
violations);
}
if (violations.isEmpty()) return Optional.empty();
StringBuilder sb = new StringBuilder();
sb.append(violations.size())
.append(" .mvn/jvm.config file(s) contain # comment lines:\n");
for (String v : violations) {
sb.append(" • ").append(v).append('\n');
}
sb.append(" Maven parses .mvn/jvm.config as raw JVM arguments\n");
sb.append(" — '#' at column 0 is passed to the JVM launcher as a\n");
sb.append(" main-class name and crashes the build. Delete the\n");
sb.append(" offending line; put commentary in an adjacent file.");
return Optional.of(sb.toString());
}
},
/**
* Every workspace subproject's root POM must either declare
* {@code <distributionManagement>} locally or have a {@code <parent>}
* block to inherit from (#346, surfaced by #343 when {@code its/}
* was missing both).
*
* <p>Site goals — specifically {@code site:stage}, which the
* workspace release cascade runs — fail with
* <em>"Missing distribution management in project ..."</em> when
* neither is present. That failure surfaces deep inside the
* release flow, after some subprojects have already tagged. This
* preflight catches the missing declaration upfront so the
* release-draft is authoritative.
*/
SUBPROJECT_HAS_DISTRIBUTION_MANAGEMENT(
"Every subproject root POM resolves <distributionManagement>") {
@Override
public Optional<String> check(PreflightContext ctx) {
File root = ctx.workspaceRoot();
List<String> violations = new ArrayList<>();
for (String name : ctx.subprojects()) {
File pom = new File(new File(root, name), "pom.xml");
if (!pom.isFile()) continue;
String content;
try {
content = Files.readString(pom.toPath(),
StandardCharsets.UTF_8);
} catch (IOException e) {
// Best-effort — preflight does not fail on read errors
continue;
}
if (!hasDistributionManagementOrParent(content)) {
violations.add(name + " (no <distributionManagement> "
+ "or <parent> in its root POM)");
}
}
if (violations.isEmpty()) return Optional.empty();
StringBuilder sb = new StringBuilder();
sb.append(violations.size())
.append(" subproject(s) missing <distributionManagement>:\n");
for (String v : violations) {
sb.append(" • ").append(v).append('\n');
}
sb.append(" site:stage during the workspace release cascade\n");
sb.append(" will fail with \"Missing distribution management in\n");
sb.append(" project ...\". Either declare\n");
sb.append(" <distributionManagement><site>...</site>... directly\n");
sb.append(" on the subproject's root POM, or add a <parent>\n");
sb.append(" block inheriting from one of the IKE parents (e.g.\n");
sb.append(" network.ike.platform:ike-parent).");
return Optional.of(sb.toString());
}
},
/**
* No subproject's {@code <properties>} block may declare a
* locally-overriding value for any IKE-foundation property name
* (#346, surfaced by the {@code its/} pom property-shadowing in
* the v150 cascade).
*
* <p>Foundation properties (
* {@code ike-tooling.version},
* {@code ike-docs.version},
* {@code ike-platform.version}) are set by {@code ike-parent}'s
* inheritance chain. Local overrides silently shadow the
* workspace's intended versions and pin plugins to old releases
* that may lack newer goals. Preferred discipline: namespace
* local overrides under {@code it.*}, {@code local.*}, or a
* project-specific prefix that doesn't collide with the
* foundation set.
*/
NO_FOUNDATION_PROPERTY_SHADOWING(
"No subproject overrides ike-foundation property names") {
@Override
public Optional<String> check(PreflightContext ctx) {
File root = ctx.workspaceRoot();
List<String> violations = new ArrayList<>();
for (String name : ctx.subprojects()) {
File pom = new File(new File(root, name), "pom.xml");
if (!pom.isFile()) continue;
String content;
try {
content = Files.readString(pom.toPath(),
StandardCharsets.UTF_8);
} catch (IOException e) {
continue;
}
for (String prop : FOUNDATION_PROPERTY_NAMES) {
if (shadowsProperty(content, prop)) {
violations.add(name + "/pom.xml shadows <" + prop
+ "> (inherited from ike-parent)");
}
}
}
if (violations.isEmpty()) return Optional.empty();
StringBuilder sb = new StringBuilder();
sb.append(violations.size())
.append(" IKE-foundation property shadow(s):\n");
for (String v : violations) {
sb.append(" • ").append(v).append('\n');
}
sb.append(" These property names control plugin resolution for\n");
sb.append(" every project inheriting ike-parent. Local overrides\n");
sb.append(" pin plugins to whatever version the subproject\n");
sb.append(" declares, often pre-dating goals the release mission\n");
sb.append(" expects (e.g. ike-tooling 126 lacks #335's\n");
sb.append(" render-spdx-licenses). Rename the local property to\n");
sb.append(" an `it.*` / `local.*` namespace and update any\n");
sb.append(" references to use the new name.");
return Optional.of(sb.toString());
}
},
/**
* No on-disk gh-pages-style site output leaks at
* {@code <projectDir>/<artifactId>/<artifactId>/index.html} —
* whether or not git tracks them.
*
* <p>Why this exists despite {@link #WORKING_TREE_CLEAN} already
* detecting committed leaks: once an operator adds
* {@code .gitignore} entries (the standing workaround in
* ike-issues#358), the leak files are no longer reported by
* {@code git status}, so {@link #WORKING_TREE_CLEAN} thinks the
* tree is clean — but the files keep getting regenerated under
* the working tree on every release flow. This check scans the
* filesystem directly so the operator sees the leak even when
* git has been told to ignore it.
*
* <p>Pattern: a directory named exactly the same as a known
* cascade artifactId living inside another directory named the
* same, containing an {@code index.html} — signature of an
* {@code mvn site} render that escaped {@code target/}. We check
* both the workspace root (where workspace-root and subproject
* artifactIds can both shadow) and each subproject root.
* ike-issues#358.
*/
NO_ON_DISK_GHPAGES_LEAK(
"No on-disk gh-pages-style site output leaks under any "
+ "project's working tree") {
@Override
public Optional<String> check(PreflightContext ctx) {
File root = ctx.workspaceRoot();
List<String> artifactIds = collectCascadeArtifactIds(ctx);
if (artifactIds.isEmpty()) return Optional.empty();
List<Path> leaks = new ArrayList<>();
// Workspace root: any artifactId in the cascade can shadow.
detectGhPagesLeakDirs(root, artifactIds, leaks);
// Each subproject root: typically just its own artifactId,
// but cheap to check the full cascade set in case of
// cross-shadowing during a multi-module workspace run.
for (String name : ctx.subprojects()) {
File sub = new File(root, name);
if (!sub.isDirectory()) continue;
detectGhPagesLeakDirs(sub, artifactIds, leaks);
}
if (leaks.isEmpty()) return Optional.empty();
StringBuilder sb = new StringBuilder();
sb.append(leaks.size())
.append(" on-disk gh-pages leak director")
.append(leaks.size() == 1 ? "y" : "ies")
.append(" found:\n");
for (Path leak : leaks) {
Path rel;
try {
rel = root.toPath().relativize(leak);
} catch (IllegalArgumentException ignore) {
rel = leak;
}
sb.append(" • ").append(rel).append('\n');
}
sb.append(" These directories hold rendered Maven Site\n");
sb.append(" output (index.html + css/ + images/) that\n");
sb.append(" escaped target/ during a release-flow\n");
sb.append(" `mvn site:stage` invocation. .gitignore may\n");
sb.append(" already block them from being committed, but\n");
sb.append(" the files keep coming back. The underlying\n");
sb.append(" cause was fixed in ike-platform's ike-parent\n");
sb.append(" (see ike-issues#358 closing comment); when the\n");
sb.append(" next ike-platform release ships and this\n");
sb.append(" workspace bumps to it, the leak stops at the\n");
sb.append(" source. Until then, safe to delete the\n");
sb.append(" directories above — canonical published\n");
sb.append(" content lives on each repo's gh-pages branch.\n");
sb.append(" Copy-paste cleanup:\n");
for (Path leak : leaks) {
Path rel;
try {
rel = root.toPath().relativize(leak);
} catch (IllegalArgumentException ignore) {
rel = leak;
}
// Strip the trailing /<artifactId>/<artifactId> down to
// /<artifactId> so the rm wipes the whole top-level
// leak dir (which has nothing legitimate in it). For
// workspace-root leaks the path is <artifactId>/; for
// subproject leaks it's <subprojectDir>/<artifactId>/.
Path parent = rel.getParent();
if (parent != null) {
sb.append(" rm -rf ").append(parent).append('\n');
} else {
sb.append(" rm -rf ").append(rel).append('\n');
}
}
sb.append(" ike-issues#358.");
return Optional.of(sb.toString());
}
},
/**
* No subproject root POM (nor the workspace root) declares a
* {@code <distributionManagement><site><url>} starting with
* {@code scpexe://} — that wagon was retired in
* ike-issues#304 in favor of the GitHub Pages publish path
* ({@code https://ike.network/<repo>/} via the org CNAME).
*
* <p>A surviving {@code scpexe://} is a silent release-blocker:
* the goal that consumes the URL only fails at the step that
* tries to use the wagon, after subproject releases have
* already started. This check catches it at draft time so the
* operator fixes the URL before any tags ship.
*
* <p>ike-issues#372.
*/
NO_SCPEXE_SITE_URLS(
"No POM declares a scpexe:// <site><url> "
+ "(retired in #304)") {
@Override
public Optional<String> check(PreflightContext ctx) {
File root = ctx.workspaceRoot();
List<String> violations = new ArrayList<>();
scanPomForScpexeSiteUrl(new File(root, "pom.xml"),
"workspace root", violations);
for (String name : ctx.subprojects()) {
scanPomForScpexeSiteUrl(
new File(new File(root, name), "pom.xml"),
name, violations);
}
if (violations.isEmpty()) return Optional.empty();
StringBuilder sb = new StringBuilder();
sb.append(violations.size())
.append(" POM(s) declare a scpexe:// <site><url>:\n");
for (String v : violations) {
sb.append(" • ").append(v).append('\n');
}
sb.append(" scpexe:// was retired in ike-issues#304.\n");
sb.append(" Canonical site distribution is now GitHub Pages\n");
sb.append(" served at https://ike.network/<repo>/ via the\n");
sb.append(" org CNAME. Update each pom's\n");
sb.append(" <distributionManagement><site><url> to the\n");
sb.append(" https:// form (inheriting from ike-parent's\n");
sb.append(" https://ike.network/${project.artifactId}/\n");
sb.append(" default usually suffices — delete the override).\n");
sb.append(" ike-issues#372.");
return Optional.of(sb.toString());
}
},
/**
* When a subproject's {@code <parent>} declares the same GA as the
* workspace aggregator's own {@code <parent>}, enforce the two
* coherence rules from #324:
*
* <ol>
* <li>{@code <parent><version>} matches the workspace's.</li>
* <li>{@code <parent>} block includes an empty
* {@code <relativePath/>} to prevent the Maven 4
* parent-mission error.</li>
* </ol>
*
* <p>This preflight is the release-gate analog of
* {@code ws:scaffold-draft}'s coherence check (#324; folded
* from the retired {@code ws:verify} per #393). The scaffold-draft
* check warns; the preflight blocks. Composed via Preflight so
* release-draft surfaces it as a warning and release-publish
* promotes it to a hard error.
*/
PARENT_COHERENCE(
"Subprojects sharing the workspace's parent GA have "
+ "matching version + <relativePath/>") {
@Override
public Optional<String> check(PreflightContext ctx) {
File root = ctx.workspaceRoot();
File workspacePom = new File(root, "pom.xml");
if (!workspacePom.isFile()) return Optional.empty();
String workspaceContent;
try {
workspaceContent = Files.readString(workspacePom.toPath(),
StandardCharsets.UTF_8);
} catch (IOException e) {
return Optional.empty();
}
String wsParentGA = extractParentGa(workspaceContent);
String wsParentVersion = extractParentVersion(workspaceContent);
if (wsParentGA == null) return Optional.empty();
List<String> violations = new ArrayList<>();
for (String name : ctx.subprojects()) {
// Tag-aligned members are pinned at a released tag and
// outside the release cascade (#973): their parent
// matches the pinned tag's era by design, not the
// workspace tip's, so coherence-with-the-tip does not
// apply to them (ike-issues#1000, mission two).
if (ctx.graph() != null) {
network.ike.workspace.Subproject sub =
ctx.graph().manifest().subprojects().get(name);
if (sub != null && !sub.isSnapshotAligned()) continue;
}
File pom = new File(new File(root, name), "pom.xml");
if (!pom.isFile()) continue;
String content;
try {
content = Files.readString(pom.toPath(),
StandardCharsets.UTF_8);
} catch (IOException e) {
continue;
}
String subParentGA = extractParentGa(content);
String subParentVersion = extractParentVersion(content);
if (subParentGA == null
|| !subParentGA.equals(wsParentGA)) {
continue; // out of scope — different parent
}
if (wsParentVersion != null
&& !wsParentVersion.equals(subParentVersion)) {
violations.add(name + " parent " + subParentGA + ":"
+ subParentVersion + " != workspace " + wsParentGA
+ ":" + wsParentVersion + " (#324)");
continue;
}
if (!network.ike.plugin.ws.PomParentSupport
.hasEmptyRelativePathInContent(content)) {
violations.add(name + " parent " + subParentGA + ":"
+ subParentVersion + " missing empty "
+ "<relativePath/> (#324 mission prevention)");
}
}
if (violations.isEmpty()) return Optional.empty();
StringBuilder sb = new StringBuilder();
sb.append(violations.size())
.append(" parent-coherence violation(s):\n");
for (String v : violations) {
sb.append(" • ").append(v).append('\n');
}
sb.append(" Subprojects sharing the workspace's parent GA must\n");
sb.append(" agree on parent version AND declare an empty\n");
sb.append(" <relativePath/> to prevent the Maven 4\n");
sb.append(" \"parents form a mission\" error. "
+ WsGoal.SCAFFOLD_DRAFT.qualified() + "\n");
sb.append(" reports the same coherence check as a warning (folded\n");
sb.append(" from the retired ws:verify per #393); the release gate\n");
sb.append(" promotes it to a hard requirement.");
return Optional.of(sb.toString());
}
},
/**
* Every cloned subproject's three branch axes agree: the
* {@code workspace.yaml} {@code branch:} declaration, the on-disk
* checkout, and the {@code .ike/vcs-state} record. The three drifted
* silently for a week on ike-starter-set — manifest declared a feature
* branch while checkout and state file sat on {@code main}, and the
* VCS-bridge sync actively enforced the state file OVER the manifest
* (IKE-Network/ike-issues#904). Surfaced warn-only on every goal by
* {@code AbstractWorkspaceMojo}; {@code ws:lint} reports it with the
* rest of the conditions.
*/
BRANCH_COHERENCE(
"workspace.yaml, checkout, and .ike/vcs-state agree on every "
+ "subproject's branch") {
@Override
public Optional<String> check(PreflightContext ctx) {
WorkspaceGraph graph = ctx.graph();
if (graph == null) return Optional.empty();
File root = ctx.workspaceRoot();
List<String> disagreements = new ArrayList<>();
for (Map.Entry<String, Subproject> entry
: graph.manifest().subprojects().entrySet()) {
String name = entry.getKey();
String declared = entry.getValue().branch();
if (declared == null) continue;
File dir = new File(root, name);
if (!new File(dir, ".git").exists()) continue;
String checkout = currentBranchOf(dir);
if (checkout.isEmpty()) continue; // detached/unreadable — not this check's call
String recorded = VcsState.readFrom(dir.toPath())
.map(VcsState::branch).orElse(null);
boolean coherent = declared.equals(checkout)
&& (recorded == null || recorded.equals(declared));
if (!coherent) {
disagreements.add(name + ": yaml=" + declared
+ ", checkout=" + checkout
+ ", vcs-state="
+ (recorded == null ? "(none)" : recorded));
}
}
if (disagreements.isEmpty()) return Optional.empty();
StringBuilder sb = new StringBuilder();
sb.append(disagreements.size())
.append(" subproject(s) disagree across workspace.yaml / ")
.append("checkout / .ike/vcs-state:\n");
for (String d : disagreements) {
sb.append(" • ").append(d).append('\n');
}
sb.append(" The VCS bridge treats .ike/vcs-state as the branch of\n");
sb.append(" record: a checkout that disagrees with it is switched\n");
sb.append(" back by the next ws:* goal's sync, so silent drift\n");
sb.append(" compounds (ike-issues#904).\n");
sb.append(" Align checkouts AND bridge state to the manifest:\n");
sb.append(" mvn ").append(WsGoal.RECONCILE_BRANCHES_PUBLISH.qualified())
.append(" -Dfrom=manifest\n");
sb.append(" Or accept the checkouts as truth and amend workspace.yaml:\n");
sb.append(" mvn ").append(WsGoal.RECONCILE_BRANCHES_PUBLISH.qualified());
return Optional.of(sb.toString());
}
},
/**
* Every member with an {@code origin} remote — and the workspace root
* itself — must accept a push from this operator, established by a
* {@code git push --dry-run} that transfers nothing and creates no
* refs (IKE-Network/ike-issues#960).
*
* <p>Every other condition here inspects local state. This one alone
* asks the remote a question only the remote can answer, because a
* goal that ends in "push every member" can otherwise discover
* unwritability only after it has already squashed, rewritten
* {@code workspace.yaml}, committed, and pushed the members it
* <em>could</em> write — which is exactly how a
* {@code feature-finish-squash-publish} against {@code ike-komet-wsr}
* stranded a half-landed working set. A mixed-org working set
* (members under one org, the root under another) makes
* partial-permission the normal case, not an exotic one.
*
* <p>Read probes cannot substitute: {@code ls-remote} and the
* {@code remoteSha()} calls already in the finish path authenticate
* and prove <em>read</em> access, which on a public repo succeeds for
* an operator who cannot write — reporting green on a run that cannot
* complete.
*
* <p>Costs one network round-trip per member, so it belongs on goals
* that are about to do far more network work anyway.
*/
PUSH_ACCESS("Every member's origin accepts a push from this operator") {
@Override
public Optional<String> check(PreflightContext ctx) {
File root = ctx.workspaceRoot();
Map<String, File> targets = new LinkedHashMap<>();
if (ctx.subprojects() != null) {
for (String name : ctx.subprojects()) {
targets.put(name, new File(root, name));
}
}
targets.put(WORKSPACE_ROOT_NAME, root);
List<String> denied = new ArrayList<>();
List<String> undetermined = new ArrayList<>();
for (Map.Entry<String, File> entry : targets.entrySet()) {
File dir = entry.getValue();
if (!new File(dir, ".git").exists()) continue;
if (!ReleaseSupport.hasRemote(dir, DEFAULT_REMOTE)) continue;
String branch = currentBranchOf(dir);
if (branch.isEmpty()) continue; // detached — nothing to probe
VcsOperations.PushAccess access =
VcsOperations.probePushAccess(dir, DEFAULT_REMOTE, branch);
String label = entry.getKey() + " (" + branch + ")";
switch (access.status()) {
case WRITABLE -> { }
case DENIED -> denied.add(label + ":\n"
+ indent(access.detail()));
case UNDETERMINED -> undetermined.add(label + ":\n"
+ indent(access.detail()));
}
}
if (denied.isEmpty() && undetermined.isEmpty()) {
return Optional.empty();
}
StringBuilder sb = new StringBuilder();
if (!denied.isEmpty()) {
sb.append(denied.size())
.append(" member(s) refuse a push from this operator:\n");
for (String d : denied) {
sb.append(" • ").append(d).append('\n');
}
}
if (!undetermined.isEmpty()) {
sb.append(undetermined.size())
.append(" member(s) could not be probed ")
.append("(offline, or an unrecognized git error):\n");
for (String u : undetermined) {
sb.append(" • ").append(u).append('\n');
}
}
sb.append(" Nothing has been mutated — this check runs before\n");
sb.append(" any squash, commit, or push, so the working set is\n");
sb.append(" still coherent.\n");
if (!denied.isEmpty()) {
sb.append(" To resolve, for each member above either:\n");
sb.append(" • have an admin grant write access to the\n");
sb.append(" repository (a read-only collaborator can\n");
sb.append(" fetch and clone but not push), or\n");
sb.append(" • check the remote URL and the SSH identity it\n");
sb.append(" selects — `git -C <member> remote -v` — since\n");
sb.append(" a host alias can route to the wrong account.\n");
}
return Optional.of(sb.toString().stripTrailing());
}
};
/** Special marker used when the workspace root itself has uncommitted changes. */
public static final String WORKSPACE_ROOT_NAME = "workspace root";
/** The remote every push-oriented condition probes. */
static final String DEFAULT_REMOTE = "origin";
/**
* Indent a possibly multi-line git message so it nests under a
* bullet in a remediation block.
*
* @param text the raw message
* @return the message with every line indented, never {@code null}
*/
static String indent(String text) {
if (text == null || text.isBlank()) return " (no output)";
return text.lines()
.map(l -> " " + l.strip())
.reduce((a, b) -> a + "\n" + b)
.orElse("");
}
private final String description;
PreflightCondition(String description) {
this.description = description;
}
/** Short human description of what this condition enforces. */
public String description() {
return description;
}
/**
* Evaluate the condition against the given context.
*
* @param ctx the preflight context
* @return {@link Optional#empty()} if the condition is satisfied;
* a remediation message otherwise
*/
public abstract Optional<String> check(PreflightContext ctx);
// ── Shared helpers ──────────────────────────────────────────────
static String gitStatus(File dir) {
try {
return ReleaseSupport.execCapture(dir,
"git", "status", "--porcelain").trim();
} catch (MojoException e) {
return "";
}
}
/**
* The current branch of the repository at {@code dir}, or an empty
* string when HEAD is detached or the branch cannot be read — callers
* treat absence of a branch name as nothing to compare.
*
* @param dir the repository root directory
* @return the current branch name, or {@code ""} when unreadable
*/
static String currentBranchOf(File dir) {
try {
String out = ReleaseSupport.execCapture(dir,
"git", "branch", "--show-current");
return out == null ? "" : out.trim();
} catch (MojoException e) {
return "";
}
}
/**
* Return {@code true} when the given {@code git status --porcelain}
* output mentions paths matching a gh-pages-style site output
* layout: {@code <something>/<same>/<site files>} doubling, or
* {@code examples/<name>/<site files>}. The signal files we look
* for ({@code bom.json}, {@code built-with.html},
* {@code dependencies.html}, {@code dependency-management.html},
* {@code distribution-management.html}, {@code project-info.html})
* are emitted by maven-project-info-reports-plugin during
* site:stage and don't belong in source control.
*
* <p>Used by {@link #WORKING_TREE_CLEAN} to amplify the diagnostic
* when an operator is about to bump a workspace whose main tree
* contains leaked gh-pages content. ike-issues#358.
*
* @param porcelain raw {@code git status --porcelain} output
* @return {@code true} when at least one entry matches
*/
static boolean containsGhPagesLeakPattern(String porcelain) {
if (porcelain == null || porcelain.isBlank()) return false;
for (String line : porcelain.split("\n")) {
String path = line.length() > 3 ? line.substring(3).trim() : "";
if (path.endsWith("/bom.json")
|| path.endsWith("/built-with.html")
|| path.endsWith("/dependencies.html")
|| path.endsWith("/dependency-management.html")
|| path.endsWith("/distribution-management.html")
|| path.endsWith("/project-info.html")) {
return true;
}
}
return false;
}
/**
* Read every {@code pom.xml} reachable via the workspace root or any
* subproject in the context, extracting each one's {@code <artifactId>}.
* Used to seed on-disk gh-pages leak detection — the leak directory
* names always equal one of these.
*
* <p>Best-effort: unreadable POMs are silently skipped. Returns a
* de-duplicated list in encounter order (workspace first, then
* subprojects in their declared order).
*
* @param ctx the preflight context
* @return distinct artifactIds discovered across the workspace
*/
static List<String> collectCascadeArtifactIds(PreflightContext ctx) {
File root = ctx.workspaceRoot();
java.util.LinkedHashSet<String> seen = new java.util.LinkedHashSet<String>();
addArtifactIdIfPresent(new File(root, "pom.xml"), seen);
for (String name : ctx.subprojects()) {
addArtifactIdIfPresent(new File(new File(root, name), "pom.xml"),
seen);
}
return new ArrayList<>(seen);
}
/**
* Every {@code pom.xml} inside a member repository — build output
* and nested repositories excluded. The same tree the release
* mission's version pass walks, so what this gate scans is exactly
* what the mission deploys.
*
* @param memberDir the member repository directory
* @return the member's POM files; on a walk failure, just the root
* POM (preflights degrade rather than fail on IO)
*/
private static List<File> memberPoms(File memberDir) {
java.nio.file.Path root = memberDir.toPath();
try (java.util.stream.Stream<java.nio.file.Path> tree =
java.nio.file.Files.walk(root)) {
return tree
.filter(p -> p.getFileName().toString().equals("pom.xml"))
.filter(p -> !p.toString().contains(
File.separator + "target" + File.separator))
.filter(p -> !nestedRepositoryBetween(root, p))
.map(java.nio.file.Path::toFile)
.toList();
} catch (IOException e) {
return List.of(new File(memberDir, "pom.xml"));
}
}
/**
* Whether a POM lies inside a repository nested under this one —
* a twin of the release mission's repository-boundary guard: such a
* POM belongs to another member and is scanned (or not) as part of
* that member, never through an enclosing tree.
*
* @param repoRoot the repository whose tree is being walked
* @param pom a POM found beneath it
* @return {@code true} when some directory between the two is
* itself a repository
*/
private static boolean nestedRepositoryBetween(java.nio.file.Path repoRoot,
java.nio.file.Path pom) {
java.nio.file.Path dir = pom.getParent();
while (dir != null && !dir.equals(repoRoot)) {
if (java.nio.file.Files.exists(dir.resolve(".git"))) return true;
dir = dir.getParent();
}
return false;
}
/**
* The coordinate keys a member's own tree produces: every module's
* {@code artifactId} plus, where the groupId is spelled out or
* inherited from the module's parent block, the full
* {@code groupId:artifactId}. References to these move with the
* member's own version pass (intermediate aggregators included —
* they are nobody's release-plan artifact but are retargeted all
* the same).
*
* @param poms the member's POM files
* @param memberDir the member repository directory (unused except
* as documentation anchor for the Maven 4.1
* directory-name inference below)
* @return artifactId and groupId:artifactId keys of the member's
* own modules
*/
private static Set<String> ownCoordinateKeys(List<File> poms,
File memberDir) {
Set<String> keys = new java.util.LinkedHashSet<>();
// Depth-sorted so parents resolve before the modules that
// inherit from them: Maven 4.1 modules may declare an empty
// <parent/> and no groupId (tinkar-core's reasoner aggregator
// does), inheriting the groupId from the nearest ancestor
// module by directory — the same resolution the workspace
// extension's index performs.
List<File> ordered = new ArrayList<>(poms);
ordered.sort(java.util.Comparator.comparingInt(
p -> p.toPath().getNameCount()));
Map<java.nio.file.Path, String> groupByDir = new LinkedHashMap<>();
for (File pom : ordered) {
try {
org.apache.maven.api.model.Model model =
network.ike.plugin.ws.PomModel.parse(pom.toPath())
.model();
String artifactId = model.getArtifactId() != null
? model.getArtifactId()
// Maven 4.1 inference: an absent artifactId is
// the module's directory name.
: pom.getParentFile().getName();
String groupId = model.getGroupId() != null
? model.getGroupId()
: model.getParent() != null
&& model.getParent().getGroupId() != null
? model.getParent().getGroupId()
: nearestAncestorGroupId(groupByDir,
memberDir.toPath(),
pom.getParentFile().toPath());
keys.add(artifactId);
if (groupId != null && !groupId.contains("${")) {
groupByDir.put(pom.getParentFile().toPath(), groupId);
keys.add(groupId + ":" + artifactId);
}
} catch (IOException e) {
// Best-effort — preflight does not fail on read errors.
}
}
return keys;
}
/**
* The groupId of the nearest already-resolved ancestor module, for
* Maven 4.1 modules that infer theirs from the directory tree.
*
* @param groupByDir module directory → resolved groupId, filled in
* depth order
* @param repoRoot the member repository root (walk stops here)
* @param moduleDir the inferring module's directory
* @return the nearest ancestor's groupId, or {@code null} when no
* ancestor inside the repository has resolved one
*/
private static String nearestAncestorGroupId(
Map<java.nio.file.Path, String> groupByDir,
java.nio.file.Path repoRoot, java.nio.file.Path moduleDir) {
java.nio.file.Path dir = moduleDir.getParent();
while (dir != null && dir.startsWith(repoRoot)) {
String groupId = groupByDir.get(dir);
if (groupId != null) return groupId;
dir = dir.getParent();
}
return null;
}
/**
* Whether the current release mission resolves a snapshot reference
* before anything deploys: the referenced coordinate is either one
* the mission's release plan de-qualifies (the version pass retargets
* every reference to it) or one the member's own tree produces
* (the member's version pass moves it). The
* {@code ${project.groupId}} sibling idiom leaves the groupId
* unresolved at scan time, so identity falls back to the
* artifactId alone — mirroring the workspace extension's
* resolution rule.
*
* @param ctx the preflight context carrying
* {@link PreflightContext#missionReleasedArtifacts()}
* @param ownKeys the member's own coordinate keys
* @param violation the scanned SNAPSHOT reference
* @return true when the mission itself resolves the reference
*/
private static boolean cycleResolvesReference(
PreflightContext ctx, Set<String> ownKeys,
SnapshotScanner.Violation violation) {
String location = violation.location();
String ga;
if (location.endsWith("]")) {
// Parent sites format as "parent[groupId:artifactId]".
int open = location.lastIndexOf('[');
ga = location.substring(open + 1, location.length() - 1);
} else {
ga = location.substring(location.lastIndexOf('/') + 1);
}
Set<String> released = ctx.missionReleasedArtifacts() == null
? Set.of() : ctx.missionReleasedArtifacts();
if (ownKeys.contains(ga) || released.contains(ga)) {
return true;
}
int colon = ga.lastIndexOf(':');
if (colon < 0) {
return false;
}
String groupId = ga.substring(0, colon);
if (!groupId.contains("${")) {
return false;
}
String artifactId = ga.substring(colon + 1);
if (ownKeys.contains(artifactId)) {
return true;
}
for (String key : released) {
int c = key.lastIndexOf(':');
if (c >= 0 && key.substring(c + 1).equals(artifactId)) {
return true;
}
}
return false;
}
/**
* Whether a SNAPSHOT property violation is resolved by the current
* release mission rather than leaking (ike-issues#981): the property
* is the owning member's manifest-declared {@code version-property}
* hint for a depends-on edge whose target member releases in this
* mission. The release loop's catch-up alignment substitutes the
* target's released version before the owning consumer releases, so
* the SNAPSHOT value never reaches a released POM. Everything else —
* a property tracking a member outside the release set, or an
* unrelated SNAPSHOT property — keeps failing the gate.
*
* @param ctx the preflight context; exemptions require both
* {@link PreflightContext#releaseSet()} and
* {@link PreflightContext#graph()}
* @param owner the subproject whose POM carries the violation
* @param violation the scanned SNAPSHOT property violation
* @return true when the mission's catch-up alignment resolves it
*/
private static boolean resolvedByReleaseMission(
PreflightContext ctx, String owner,
SnapshotScanner.Violation violation) {
if (ctx.releaseSet() == null || ctx.graph() == null) {
return false;
}
String location = violation.location();
int slash = location.lastIndexOf('/');
if (slash < 0
|| !location.substring(0, slash).endsWith("properties")) {
return false;
}
String property = location.substring(slash + 1);
// The mission's own release plan is the authority: any property
// it rewrites is de-qualified before the reactor builds, so it
// cannot reach a released POM as a SNAPSHOT. The manifest-hint
// path below stays for callers that have no plan to offer, but
// it exempts strictly less than the plan resolves — which is
// how six already-planned properties blocked the komet mission
// (ike-issues#1004).
if (ctx.missionResolvedProperties() != null
&& ctx.missionResolvedProperties()
.contains(owner + "::" + property)) {
return true;
}
Subproject ownerEntry =
ctx.graph().manifest().subprojects().get(owner);
if (ownerEntry == null || ownerEntry.dependsOn() == null) {
return false;
}
for (Dependency dep : ownerEntry.dependsOn()) {
if (property.equals(dep.versionProperty())
&& ctx.releaseSet().contains(dep.subproject())) {
return true;
}
}
return false;
}
private static void addArtifactIdIfPresent(File pom,
java.util.Set<String> accum) {
if (!pom.isFile()) return;
try {
String content = Files.readString(pom.toPath(),
StandardCharsets.UTF_8);
String artifactId = extractTopLevelArtifactId(content);
if (artifactId != null && !artifactId.isBlank()) {
accum.add(artifactId);
}
} catch (IOException ignore) {
// best-effort
}
}
/**
* Extract the top-level {@code <artifactId>} from POM XML — the
* project's own artifactId, not a {@code <parent>} or
* {@code <dependency>} reference. Uses a tag-after-tag scan so it
* doesn't drag in an XML parser dependency.
*
* @param pomContent POM XML as a string
* @return the top-level artifactId, or {@code null} if not found
*/
static String extractTopLevelArtifactId(String pomContent) {
if (pomContent == null) return null;
// Skip any leading <parent>...</parent> block so we don't return
// the parent's artifactId.
int searchFrom = 0;
int parentOpen = pomContent.indexOf("<parent>");
if (parentOpen >= 0) {
int parentClose = pomContent.indexOf("</parent>", parentOpen);
if (parentClose > parentOpen) {
searchFrom = parentClose + "</parent>".length();
}
}
int open = pomContent.indexOf("<artifactId>", searchFrom);
if (open < 0) return null;
int valueStart = open + "<artifactId>".length();
int close = pomContent.indexOf("</artifactId>", valueStart);
if (close < 0) return null;
return pomContent.substring(valueStart, close).trim();
}
/**
* Scan a single POM for a {@code <distributionManagement><site>}
* block whose {@code <url>} starts with {@code scpexe://}. Used
* by {@link #NO_SCPEXE_SITE_URLS}.
*
* <p>Quietly tolerates unreadable POMs — preflight is best-
* effort. The check is substring-based on the
* {@code <distributionManagement>...</distributionManagement>}
* block so it doesn't false-positive on a scpexe wagon
* reference outside the site URL (e.g., in a
* {@code <repository>} block, though that's also gone).
*
* @param pom pom.xml to scan (may not exist)
* @param displayName label for the violation line
* ({@link #WORKSPACE_ROOT_NAME} or
* subproject name)
* @param accum accumulator for formatted violations
*/
static void scanPomForScpexeSiteUrl(File pom, String displayName,
List<String> accum) {
if (!pom.isFile()) return;
String content;
try {
content = Files.readString(pom.toPath(),
StandardCharsets.UTF_8);
} catch (IOException e) {
return; // best-effort
}
if (containsScpexeSiteUrl(content)) {
accum.add(displayName + " (pom.xml has scpexe:// "
+ "<site><url>)");
}
}
/**
* Pure-string test for an {@code scpexe://} URL inside a POM's
* {@code <distributionManagement>} block. Extracted so it's
* testable without filesystem fixtures.
*
* @param pomContent POM XML as a string
* @return {@code true} when a scpexe:// site URL is present
*/
static boolean containsScpexeSiteUrl(String pomContent) {
if (pomContent == null) return false;
int dmOpen = pomContent.indexOf("<distributionManagement>");
if (dmOpen < 0) return false;
int dmClose = pomContent.indexOf("</distributionManagement>",
dmOpen);
if (dmClose < dmOpen) return false;
String block = pomContent.substring(dmOpen, dmClose);
int siteOpen = block.indexOf("<site>");
if (siteOpen < 0) return false;
int siteClose = block.indexOf("</site>", siteOpen);
if (siteClose < siteOpen) return false;
String siteBlock = block.substring(siteOpen, siteClose);
return siteBlock.contains("scpexe://");
}
/**
* Within {@code projectDir}, look for {@code <id>/<id>/index.html}
* for each {@code id} in {@code artifactIds}. Each hit is added to
* {@code accum} as the directory containing the {@code index.html}
* (i.e., {@code projectDir/<id>/<id>}).
*
* <p>{@code index.html} is the signal we trust — the maven-site
* render always produces it, and we want to avoid flagging legit
* subdirectories that happen to share a name. The doubled-name
* pattern eliminates almost all collisions on its own; requiring
* {@code index.html} closes the rest.
*
* @param projectDir directory to search
* @param artifactIds candidate names to probe
* @param accum accumulator for hit paths
*/
static void detectGhPagesLeakDirs(File projectDir,
List<String> artifactIds,
List<Path> accum) {
if (projectDir == null || !projectDir.isDirectory()) return;
Path base = projectDir.toPath();
for (String id : artifactIds) {
Path probe = base.resolve(id).resolve(id);
if (Files.isRegularFile(probe.resolve("index.html"))) {
accum.add(probe);
}
}
}
/**
* If {@code dir/.mvn/jvm.config} exists and contains any line whose
* first non-empty character is {@code #}, append one entry per
* offending line to {@code accum}. Format:
* {@code <displayName>/.mvn/jvm.config:<lineNo>: <text>}.
*
* <p>Empty lines and whitespace-only lines are ignored. Quietly
* tolerates {@link IOException} — preflight is best-effort and
* shouldn't fail the gate over a transient read error.
*
* @param dir the directory whose {@code .mvn/jvm.config} to scan
* @param displayName name shown in the violation list
* ({@link #WORKSPACE_ROOT_NAME} for the workspace
* root, otherwise the subproject name)
* @param accum accumulator for formatted violation strings
*/
static void collectJvmConfigViolations(File dir, String displayName,
List<String> accum) {
Path config = dir.toPath().resolve(".mvn").resolve("jvm.config");
if (!Files.isRegularFile(config)) return;
try {
List<String> lines = Files.readAllLines(config, StandardCharsets.UTF_8);
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i);
String trimmed = line.stripLeading();
if (trimmed.startsWith("#")) {
accum.add(displayName + "/.mvn/jvm.config:"
+ (i + 1) + ": " + line);
}
}
} catch (IOException e) {
// Best-effort — preflight does not fail on read errors.
}
}
/**
* IKE-foundation property names that
* {@link #NO_FOUNDATION_PROPERTY_SHADOWING} flags as illegal local
* overrides.
*/
static final List<String> FOUNDATION_PROPERTY_NAMES = List.of(
"ike-tooling.version",
"ike-docs.version",
"ike-platform.version");
/**
* Return {@code true} when the POM content contains either a
* {@code <distributionManagement>} block or a {@code <parent>}
* block. Used by
* {@link #SUBPROJECT_HAS_DISTRIBUTION_MANAGEMENT} — site:stage
* needs at least one of these to resolve the deploy target.
*
* @param pomContent the POM XML as a string
* @return {@code true} when at least one block is present
*/
static boolean hasDistributionManagementOrParent(String pomContent) {
if (pomContent == null) return false;
return DISTRIBUTION_MGMT_OR_PARENT.matcher(pomContent).find();
}
/**
* Return {@code true} when the POM has a {@code <properties>} block
* containing an element whose tag name equals {@code propertyName}.
* Tolerates whitespace inside the tag value (including blank).
*
* @param pomContent the POM XML as a string
* @param propertyName the property tag name to scan for
* @return {@code true} when the property is declared at the
* project's top-level {@code <properties>}
*/
static boolean shadowsProperty(String pomContent, String propertyName) {
if (pomContent == null || propertyName == null) return false;
// Scope to <properties>...</properties> at top level — a
// <properties> nested inside a plugin <configuration> isn't
// a shadowing site.
java.util.regex.Matcher matcher = TOP_LEVEL_PROPERTIES_BLOCK.matcher(pomContent);
while (matcher.find()) {
// Comments are prose, not declarations. A POM that renames
// a shadowing property and explains why — naming the
// property it no longer declares — must not read as still
// declaring it (ike-issues#1004).
String block = XML_COMMENT.matcher(matcher.group(1))
.replaceAll("");
if (java.util.regex.Pattern
.compile("<" + java.util.regex.Pattern.quote(propertyName)
+ "\\b")
.matcher(block).find()) {
return true;
}
}
return false;
}
/** An XML comment, including its delimiters, across lines. */
private static final java.util.regex.Pattern XML_COMMENT =
java.util.regex.Pattern.compile("(?s)<!--.*?-->");
private static final java.util.regex.Pattern DISTRIBUTION_MGMT_OR_PARENT =
java.util.regex.Pattern.compile(
"(?s)<distributionManagement\\b|<parent\\b");
/**
* Extract the parent {@code groupId:artifactId} from a POM string,
* or {@code null} when no {@code <parent>} block is present.
*
* @param pomContent the POM XML
* @return {@code "groupId:artifactId"} or {@code null}
*/
static String extractParentGa(String pomContent) {
if (pomContent == null) return null;
java.util.regex.Matcher parentMatcher = java.util.regex.Pattern.compile(
"(?s)<parent\\b[^>]*>(.*?)</parent>").matcher(pomContent);
if (!parentMatcher.find()) return null;
String block = parentMatcher.group(1);
java.util.regex.Matcher gMatch = java.util.regex.Pattern.compile(
"<groupId>\\s*([^<]+?)\\s*</groupId>").matcher(block);
java.util.regex.Matcher aMatch = java.util.regex.Pattern.compile(
"<artifactId>\\s*([^<]+?)\\s*</artifactId>").matcher(block);
if (!gMatch.find() || !aMatch.find()) return null;
return gMatch.group(1).trim() + ":" + aMatch.group(1).trim();
}
/**
* Extract the parent {@code <version>} from a POM string, or
* {@code null} when no {@code <parent>} block is present or it
* lacks an explicit version.
*
* @param pomContent the POM XML
* @return the version string or {@code null}
*/
static String extractParentVersion(String pomContent) {
if (pomContent == null) return null;
java.util.regex.Matcher parentMatcher = java.util.regex.Pattern.compile(
"(?s)<parent\\b[^>]*>(.*?)</parent>").matcher(pomContent);
if (!parentMatcher.find()) return null;
String block = parentMatcher.group(1);
java.util.regex.Matcher vMatch = java.util.regex.Pattern.compile(
"<version>\\s*([^<]+?)\\s*</version>").matcher(block);
if (!vMatch.find()) return null;
return vMatch.group(1).trim();
}
/**
* Match the project's top-level {@code <properties>} block.
* Anchored to follow either {@code </artifactId>},
* {@code </version>}, or {@code </name>} so it doesn't match
* nested {@code <properties>} inside plugin {@code <configuration>}
* blocks. Best-effort — handles the common shape of IKE POMs.
*/
private static final java.util.regex.Pattern TOP_LEVEL_PROPERTIES_BLOCK =
java.util.regex.Pattern.compile(
"(?s)<properties>\\s*(.*?)\\s*</properties>");
}