ReleasePlanCompute.java
package network.ike.plugin.ws;
import network.ike.plugin.ws.PomSiteScanner.PomSiteSurvey;
import network.ike.plugin.ws.ReactorWalker.ReactorScan;
import network.ike.plugin.ws.ReleasePlan.ArtifactReleasePlan;
import network.ike.plugin.ws.ReleasePlan.GA;
import network.ike.plugin.ws.ReleasePlan.PropertyReleasePlan;
import network.ike.plugin.ws.ReleasePlan.ReferenceSite;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.SequencedMap;
import java.util.Set;
/**
* Computes an immutable {@link ReleasePlan} from a reactor scan and a
* caller-supplied list of artifact release intents.
*
* <p>This is the single source of truth for what a release or align
* operation will do. No heuristics, no mid-flight reinterpretation —
* every substitution later becomes a blind lookup in the returned
* plan.
*
* <p>Invariants enforced at compute time (fail fast, loud):
* <ul>
* <li>No {@code releaseValue} ends in {@code -SNAPSHOT}.</li>
* <li>No duplicate artifact intents for the same GA.</li>
* <li>A property that references multiple in-cascade artifacts must
* agree on their {@code releaseValue}; otherwise the property is
* ambiguous and the compute fails.</li>
* <li>The {@code releaseValue} of a {@link PropertyReleasePlan} must
* equal the {@code releaseValue} of the artifact it tracks — the
* direct guard against the
* <a href="https://github.com/IKE-Network/ike-issues/issues/209">#209</a>
* class of regression (where a property tracking an upstream
* artifact was bumped to the releasing subproject's own
* version).</li>
* </ul>
*
* <p>Thread-safe: all methods are stateless.
*
* @see ReleasePlan
* @see ReactorWalker
*/
final class ReleasePlanCompute {
private ReleasePlanCompute() {}
/**
* A subproject in the workspace, by name and root POM path.
*
* <p>Used to map a declaring POM path back to its subproject name
* when building {@link PropertyReleasePlan} entries for POMs that
* are not themselves being released this cascade.
*
* @param name workspace subproject name
* @param rootPomPath absolute path to the subproject's root pom.xml
*/
record SubprojectRoot(String name, Path rootPomPath) {
SubprojectRoot {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(rootPomPath, "rootPomPath");
}
}
/**
* One artifact the caller intends to release this cascade.
*
* @param ga Maven coordinates
* @param producingSubproject workspace subproject name
* @param rootPomPath absolute path to the subproject's
* root pom.xml, where this artifact's
* own {@code <version>} lives
* @param preReleaseValue the artifact's version before this
* cascade
* @param releaseValue the artifact's released value; must
* not end in {@code -SNAPSHOT}
* @param postReleaseValue the value to restore after the
* release is published
*/
record ArtifactReleaseIntent(
GA ga,
String producingSubproject,
Path rootPomPath,
String preReleaseValue,
String releaseValue,
String postReleaseValue) {
ArtifactReleaseIntent {
Objects.requireNonNull(ga, "ga");
Objects.requireNonNull(producingSubproject, "producingSubproject");
Objects.requireNonNull(rootPomPath, "rootPomPath");
Objects.requireNonNull(releaseValue, "releaseValue");
if (releaseValue.endsWith("-SNAPSHOT")) {
throw new IllegalArgumentException(
"releaseValue must not end in -SNAPSHOT: "
+ ga + " → " + releaseValue);
}
}
}
/**
* Compute the plan. See class-level Javadoc for the invariants
* enforced.
*
* @param scan the reactor scan: one survey per POM in the
* workspace
* @param subprojects all subprojects in the workspace by
* (name, rootPomPath) — used to attribute
* property declarations in POMs that are not
* themselves being released
* @param intents the artifacts being released this cascade, in
* release order
* @return the immutable release plan
*/
static ReleasePlan compute(
ReactorScan scan,
List<SubprojectRoot> subprojects,
List<ArtifactReleaseIntent> intents) {
// ── Index intents by GA (duplicate check) ───────────────────
Map<GA, ArtifactReleaseIntent> byGa = new LinkedHashMap<>();
for (ArtifactReleaseIntent intent : intents) {
if (byGa.put(intent.ga(), intent) != null) {
throw new IllegalArgumentException(
"duplicate release intent for " + intent.ga());
}
}
// ── Extend in-cascade coverage to co-released sub-artifacts ──
// A released subproject produces more than just the intent's GA:
// any POM in that subproject's reactor (the root POM plus every
// <module>/<subproject> descendant) is co-released at the same
// version. Without this, a property like ${ike-tooling.version}
// whose references target sub-artifacts (ike-maven-plugin,
// ike-build-standards) would not resolve to the ike-tooling
// intent, and the property would silently stay at its pre-value.
//
// coveredGa: every GA produced anywhere under any intent's
// rootPomPath's parent directory → that intent
Map<GA, ArtifactReleaseIntent> coveredGa = new LinkedHashMap<>();
coveredGa.putAll(byGa);
for (ArtifactReleaseIntent intent : intents) {
Path intentReactorDir = intent.rootPomPath().getParent()
.toAbsolutePath().normalize();
for (PomSiteSurvey survey : scan.surveys()) {
GA selfGa = survey.selfGa();
if (selfGa == null) continue;
Path surveyPath = survey.pomPath().toAbsolutePath().normalize();
if (!surveyPath.startsWith(intentReactorDir)) continue;
ArtifactReleaseIntent existing = coveredGa.putIfAbsent(
selfGa, intent);
if (existing != null && existing != intent
&& !existing.releaseValue().equals(intent.releaseValue())) {
throw new IllegalStateException(
"GA " + selfGa + " is produced under multiple"
+ " releasing subprojects with conflicting"
+ " releaseValues: "
+ existing.ga() + " → " + existing.releaseValue()
+ " and "
+ intent.ga() + " → " + intent.releaseValue());
}
}
}
// ── Build artifact plans: every site targeting this GA ──────
SequencedMap<GA, ArtifactReleasePlan> artifactPlans =
new LinkedHashMap<>();
for (ArtifactReleaseIntent intent : intents) {
List<ReferenceSite> sites = new ArrayList<>();
for (PomSiteSurvey survey : scan.surveys()) {
for (ReferenceSite site : survey.sites()) {
if (intent.ga().equals(site.targetGa())) {
sites.add(site);
}
}
}
artifactPlans.put(intent.ga(), new ArtifactReleasePlan(
intent.ga(),
intent.producingSubproject(),
intent.rootPomPath(),
intent.preReleaseValue(),
intent.releaseValue(),
intent.postReleaseValue(),
sites));
}
// ── Find properties that track an in-cascade artifact ───────
// propertyName → set of target GAs referenced via ${propertyName}.
// Maven built-in expressions like ${project.version} or
// ${project.parent.version} are excluded: they resolve locally
// per POM at build time and never track a workspace-level
// property declaration. Including them would falsely conflate
// self-references across different releasing subprojects.
Map<String, Set<GA>> propertyTargets = new LinkedHashMap<>();
for (PomSiteSurvey survey : scan.surveys()) {
for (ReferenceSite site : survey.sites()) {
String text = site.textAtSite();
if (text == null
|| !text.startsWith("${")
|| !text.endsWith("}")) continue;
String propName = text.substring(2, text.length() - 1);
if (isMavenBuiltinExpression(propName)) continue;
propertyTargets
.computeIfAbsent(propName, k -> new LinkedHashSet<>())
.add(site.targetGa());
}
}
// propertyName → the in-cascade artifact it tracks (if any).
// Matches against coveredGa (direct intents + co-released
// sub-artifacts), not just the intent GA.
Map<String, ArtifactReleaseIntent> propertyTracks =
new LinkedHashMap<>();
for (Map.Entry<String, Set<GA>> entry : propertyTargets.entrySet()) {
String propName = entry.getKey();
ArtifactReleaseIntent tracked = null;
for (GA targetGa : entry.getValue()) {
ArtifactReleaseIntent candidate = coveredGa.get(targetGa);
if (candidate == null) continue;
if (tracked != null && tracked != candidate
&& !tracked.releaseValue().equals(candidate.releaseValue())) {
throw new IllegalStateException(
"property ${" + propName + "} references multiple"
+ " in-cascade artifacts with conflicting"
+ " releaseValues: "
+ tracked.ga() + " → " + tracked.releaseValue()
+ " and "
+ candidate.ga() + " → " + candidate.releaseValue());
}
tracked = candidate;
}
if (tracked != null) propertyTracks.put(propName, tracked);
}
// ── Build property plans ────────────────────────────────────
List<PropertyReleasePlan> propertyPlans = new ArrayList<>();
for (PomSiteSurvey survey : scan.surveys()) {
for (Map.Entry<String, String> decl :
survey.propertyDeclarations().entrySet()) {
String propName = decl.getKey();
ArtifactReleaseIntent tracked = propertyTracks.get(propName);
if (tracked == null) continue;
String preValue = decl.getValue();
String releaseValue = tracked.releaseValue();
String postReleaseValue = tracked.releaseValue();
List<ReferenceSite> sites = new ArrayList<>();
String placeholder = "${" + propName + "}";
for (PomSiteSurvey s : scan.surveys()) {
for (ReferenceSite site : s.sites()) {
if (placeholder.equals(site.textAtSite())) {
sites.add(site);
}
}
}
String declaringSubproject =
attributeSubproject(survey.pomPath(), subprojects);
PropertyReleasePlan plan = new PropertyReleasePlan(
propName,
survey.pomPath(),
declaringSubproject,
preValue,
releaseValue,
postReleaseValue,
sites);
// Invariant: property's releaseValue MUST equal the
// tracked artifact's releaseValue. Redundant with
// construction above, but explicit — documents intent
// and catches any future refactor that divorces them.
if (!plan.releaseValue().equals(tracked.releaseValue())) {
throw new IllegalStateException(
"property ${" + propName + "} releaseValue "
+ plan.releaseValue()
+ " does not match tracked artifact "
+ tracked.ga() + " → "
+ tracked.releaseValue()
+ " (issue #209 shape)");
}
propertyPlans.add(plan);
}
}
return new ReleasePlan(artifactPlans, propertyPlans);
}
/**
* Whether {@code expr} is a Maven-built-in expression (resolves at
* build time from the evaluator, not from any
* {@code <properties>} block).
*
* <p>These expressions are local to each POM — their values differ
* per subproject and must not be treated as in-cascade property
* trackers. Excludes the {@code project.*}, {@code pom.*},
* {@code env.*}, and {@code settings.*} namespaces, plus the
* top-level {@code basedir}.
*
* @param expr the expression inside {@code ${...}}
* @return true if Maven resolves this expression internally
*/
static boolean isMavenBuiltinExpression(String expr) {
return expr.startsWith("project.")
|| expr.startsWith("pom.")
|| expr.startsWith("env.")
|| expr.startsWith("settings.")
|| expr.equals("basedir")
|| expr.equals("project.basedir")
|| expr.equals("project.version")
|| expr.equals("project.groupId")
|| expr.equals("project.artifactId");
}
/**
* Longest-prefix match: find the subproject whose root POM's
* parent directory is an ancestor of {@code pomPath}. Returns the
* empty string if no subproject contains this POM.
*/
private static String attributeSubproject(
Path pomPath, List<SubprojectRoot> subprojects) {
Path normalized = pomPath.toAbsolutePath().normalize();
SubprojectRoot best = null;
int bestDepth = -1;
for (SubprojectRoot sp : subprojects) {
Path rootDir = sp.rootPomPath().getParent()
.toAbsolutePath().normalize();
if (!normalized.startsWith(rootDir)) continue;
int depth = rootDir.getNameCount();
if (depth > bestDepth) {
best = sp;
bestDepth = depth;
}
}
return best != null ? best.name() : "";
}
}