QualificationCascade.java
package network.ike.plugin.ws;
import network.ike.plugin.ReleaseSupport;
import org.apache.maven.api.model.Parent;
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.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Branch-qualification cascade across a subproject's whole module tree
* (IKE-Network/ike-issues#1051).
*
* <p>The two #574 qualification arms — {@code ws:add}'s add-time
* qualification and the scaffold-publish self-heal — rewrote only the
* subproject <em>root</em> POM's own {@code <version>}. A multi-module
* subproject was left internally split: every child's
* {@code <parent><version>} still named the unqualified version, so the
* children either built against a stale external parent resolved from a
* repository (inheriting the unqualified version) or failed parent
* resolution outright. Observed 2026-08-18 adding ikm-reasoner
* (21 child modules, two aggregator levels) to the incremental-reasoner
* sibling.
*
* <p>This cascade applies one version move {@code old → new} to the whole
* tree, precisely:
* <ul>
* <li>every tree POM whose own literal {@code <version>} is {@code old}
* (the root's own version; a child that redundantly declares its
* own version) is rewritten to {@code new};</li>
* <li>every tree POM whose {@code <parent>} names an <em>in-tree</em>
* POM by full groupId:artifactId and whose parent version is
* {@code old} is rewritten to {@code new} via the OpenRewrite-LST
* editor ({@link PomModel#updateParentVersion}) — never string
* replacement.</li>
* </ul>
*
* <p>What it never touches: an external {@code <parent>} (GA not produced
* by the tree — e.g. ikm-reasoner's {@code java-parent}), and dependency
* versions that merely coincide with {@code old} literally. GA matching,
* not literal string matching, is the precision guarantee (#241 set the
* precedent for full-GA matching).
*
* <p>De-qualification already cascades on the finish side
* ({@code FeatureFinishSupport.setAllVersions} +
* {@code stripAllBranchQualifiedVersions}), so the round trip is
* symmetric once both #574 arms route through here.
*/
public final class QualificationCascade {
private QualificationCascade() {}
/**
* Apply the version move {@code oldVersion → newVersion} across the
* member's whole module tree, as described in the class javadoc.
*
* <p>Failures on individual POMs (unreadable, unparseable) are logged
* and skipped rather than failing the move — matching the non-fatal
* posture of both #574 arms, whose callers treat qualification as
* self-healable on the next scaffold-publish.
*
* @param memberDir the subproject root directory
* @param oldVersion the version currently in the tree
* @param newVersion the version the tree should carry
* @param log Maven logger for per-file debug and warnings
* @return the number of POM files rewritten
* @throws IOException if the root POM cannot be rewritten
*/
public static int apply(Path memberDir, String oldVersion,
String newVersion, Log log) throws IOException {
if (oldVersion == null || newVersion == null
|| oldVersion.equals(newVersion)) {
return 0;
}
List<PomEntry> tree = readTree(memberDir, log);
Set<String> treeGAs = treeCoordinates(tree);
int changed = 0;
for (PomEntry entry : tree) {
String content = entry.model().content();
String updated = content;
Parent parent = entry.model().parent();
if (parent != null && oldVersion.equals(parent.getVersion())
&& treeGAs.contains(
ga(parent.getGroupId(), parent.getArtifactId()))) {
updated = PomModel.updateParentVersion(updated,
parent.getGroupId(), parent.getArtifactId(),
newVersion);
}
if (oldVersion.equals(entry.model().model().getVersion())) {
updated = spliceOwnVersion(updated, oldVersion, newVersion);
}
if (!updated.equals(content)) {
Files.writeString(entry.file().toPath(), updated,
StandardCharsets.UTF_8);
changed++;
log.debug(" qualification cascade: "
+ memberDir.relativize(entry.file().toPath())
+ " " + oldVersion + " → " + newVersion);
}
}
return changed;
}
/**
* Whether the member's tree still references {@code baseVersion} —
* an in-tree {@code <parent>} at that version, or a POM's own literal
* {@code <version>} at it. This is the reconciler's detection arm for
* trees whose root is already qualified but whose children were left
* behind by a pre-#1051 qualification (the state the reconciler's
* root-only read could not see).
*
* @param memberDir the subproject root directory
* @param baseVersion the unqualified version to look for
* @param log Maven logger for warnings on unreadable POMs
* @return {@code true} when at least one tree POM still carries
* {@code baseVersion} as described
*/
public static boolean hasStaleTree(Path memberDir, String baseVersion,
Log log) {
if (baseVersion == null) {
return false;
}
List<PomEntry> tree = readTree(memberDir, log);
Set<String> treeGAs = treeCoordinates(tree);
for (PomEntry entry : tree) {
Parent parent = entry.model().parent();
if (parent != null && baseVersion.equals(parent.getVersion())
&& treeGAs.contains(
ga(parent.getGroupId(), parent.getArtifactId()))) {
return true;
}
if (baseVersion.equals(entry.model().model().getVersion())) {
return true;
}
}
return false;
}
/**
* Rewrite a POM's own {@code <version>} in {@code content}, searching
* past the {@code </parent>} block so a parent version that
* coincidentally equals {@code oldVersion} is never mistaken for the
* project's own (the project {@code <version>} precedes
* {@code <dependencies>}, so the first match after {@code </parent>}
* is the project's own). The single own-version splice of the
* cascade — successor of the retired root-only
* {@code FeatureVersionReconciler.rewriteOwnVersion} (#1051).
*
* @param content the POM text
* @param oldVersion the current project version
* @param newVersion the replacement version
* @return the updated text, or {@code content} unchanged when the old
* version is absent outside the parent block
*/
public static String spliceOwnVersion(String content, String oldVersion,
String newVersion) {
int searchFrom = 0;
int parentEnd = content.indexOf("</parent>");
if (parentEnd >= 0) {
searchFrom = parentEnd + "</parent>".length();
}
String needle = "<version>" + oldVersion + "</version>";
int idx = content.indexOf(needle, searchFrom);
if (idx < 0) {
return content;
}
return content.substring(0, idx)
+ "<version>" + newVersion + "</version>"
+ content.substring(idx + needle.length());
}
// ── Tree reading ─────────────────────────────────────────────
/** One tree POM: its file and parsed model. */
private record PomEntry(File file, PomModel model) {}
/**
* Parse every project POM under {@code memberDir}. Unreadable or
* unparseable POMs are logged and skipped — they can neither be
* matched nor rewritten.
*/
private static List<PomEntry> readTree(Path memberDir, Log log) {
List<File> poms;
try {
poms = ReleaseSupport.findPomFiles(memberDir.toFile());
} catch (MojoException e) {
log.warn(" qualification cascade: cannot scan "
+ memberDir + " — " + e.getMessage());
return List.of();
}
List<PomEntry> tree = new ArrayList<>();
for (File pom : poms) {
try {
tree.add(new PomEntry(pom, PomModel.parse(pom.toPath())));
} catch (IOException e) {
log.warn(" qualification cascade: skipping " + pom
+ " — " + e.getMessage());
}
}
return tree;
}
/**
* The full groupId:artifactId set produced by the tree, groupId
* falling back to the declared parent's when inherited — the set an
* in-tree {@code <parent>} reference is matched against.
*/
private static Set<String> treeCoordinates(List<PomEntry> tree) {
Set<String> gas = new LinkedHashSet<>();
for (PomEntry entry : tree) {
String groupId = entry.model().groupId();
String artifactId = entry.model().model().getArtifactId();
if (groupId != null && artifactId != null) {
gas.add(ga(groupId, artifactId));
}
}
return gas;
}
private static String ga(String groupId, String artifactId) {
return groupId + ":" + artifactId;
}
}