GenerateBomMojo.java
package network.ike.plugin;
import org.apache.maven.api.Project;
import org.apache.maven.api.Session;
import org.apache.maven.api.model.Dependency;
import org.apache.maven.api.model.DependencyManagement;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/**
* Generate a Bill of Materials POM from another module's dependency management.
*
* <h2>Why this goal exists</h2>
*
* <p>Maven 4's consumer POM resolves property references in
* {@code <dependencies>} but <em>not</em> in {@code <dependencyManagement>}.
* When an external project imports a BOM via {@code <scope>import</scope>},
* it receives the consumer POM — so any {@code ${…}} expressions in managed
* dependency versions arrive unresolved and the build fails.
* {@code flatten-maven-plugin}, the Maven 3 solution for this class of
* problem, has not been updated for the Maven 4 model changes.</p>
*
* <p>This goal works around the limitation by reading the
* {@code <dependencyManagement>} entries from a source module (default:
* {@code ike-parent}) in the reactor, resolving every property reference to
* a literal value, and writing a standalone BOM POM. The generated POM
* replaces the stub POM for install/deploy, so external consumers get a
* fully populated BOM without any manual maintenance. The generated POM
* uses the {@code 4.0.0} model version for maximum consumer
* compatibility.</p>
*
* <h2>Usage</h2>
*
* <p>Bind this goal to a POM-packaged stub module in the reactor,
* ordered <em>after</em> the source module and the plugin module:</p>
*
* <pre>
* <plugin>
* <groupId>network.ike</groupId>
* <artifactId>ike-maven-plugin</artifactId>
* <executions>
* <execution>
* <id>generate-bom</id>
* <goals><goal>generate-bom</goal></goals>
* </execution>
* </executions>
* </plugin>
* </pre>
*/
@Mojo(name = "generate-bom",
defaultPhase = "generate-resources",
projectRequired = true)
public class GenerateBomMojo implements org.apache.maven.api.plugin.Mojo {
@org.apache.maven.api.di.Inject
private org.apache.maven.api.plugin.Log log;
/**
* Access the Maven logger.
*
* @return the logger
*/
protected org.apache.maven.api.plugin.Log getLog() { return log; }
/** The current project (injected by Maven 4). */
@org.apache.maven.api.di.Inject
private Project project;
/** Reactor projects via Maven 4 Session. */
@org.apache.maven.api.di.Inject
private Session session;
/**
* Artifact ID of the reactor module whose {@code <dependencyManagement>}
* entries should be copied into the generated BOM.
*/
@Parameter(property = "bom.source", defaultValue = "ike-parent")
private String sourceArtifactId;
/** Creates this goal instance. */
public GenerateBomMojo() {}
@Override
public void execute() throws MojoException {
// ── Find source module in reactor ────────────────────────────
List<Project> reactorProjects = session.getProjects();
Project source = reactorProjects.stream()
.filter(p -> p.getArtifactId().equals(sourceArtifactId))
.findFirst()
.orElseThrow(() -> new MojoException(
sourceArtifactId + " not found in reactor. "
+ "Ensure it is listed before this module in <subprojects>."));
DependencyManagement depMgmt = source.getModel().getDependencyManagement();
if (depMgmt == null || depMgmt.getDependencies().isEmpty()) {
throw new MojoException(
sourceArtifactId + " has no <dependencyManagement> entries.");
}
List<Dependency> deps = depMgmt.getDependencies();
// ── Convert Maven Dependency objects to BomEntry records ─────
List<BomEntry> entries = deps.stream()
.map(dep -> new BomEntry(
dep.getGroupId(), dep.getArtifactId(), dep.getVersion(),
dep.getClassifier(), dep.getType(), dep.getScope()))
.toList();
// ── Generate BOM POM ─────────────────────────────────────────
String bomXml = buildBomXml(
project.getGroupId(), project.getArtifactId(), project.getVersion(),
project.getModel().getName(), project.getModel().getDescription(),
project.getModel().getUrl(),
entries);
Path targetDir = Path.of(project.getBuild().getDirectory());
try {
Files.createDirectories(targetDir);
Path bomPom = targetDir.resolve("generated-bom.xml");
Files.writeString(bomPom, bomXml);
// TODO: Maven 4 Project is immutable — generated BOM needs a different attachment mechanism
getLog().info("Generated BOM with " + deps.size()
+ " managed entries from " + sourceArtifactId);
} catch (IOException e) {
throw new MojoException("Failed to write generated BOM", e);
}
}
// ── XML generation (pure, static, testable) ─────────────────────
/**
* Build a complete BOM POM XML string from the given project
* coordinates and dependency entries.
*
* <p>This is a pure function with no Maven or I/O dependencies,
* suitable for direct unit testing.
*
* @param groupId project group ID
* @param artifactId project artifact ID
* @param version project version
* @param name project display name (XML-escaped internally)
* @param description project description (may be null)
* @param url project URL (may be null)
* @param entries managed dependency entries
* @return well-formed POM XML
*/
public static String buildBomXml(String groupId, String artifactId,
String version, String name,
String description, String url,
List<BomEntry> entries) {
StringBuilder xml = new StringBuilder(4096);
xml.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.append("<!--\n");
xml.append(" Auto-generated BOM — do not edit.\n");
xml.append(" Generated by: ike:generate-bom\n");
xml.append("-->\n");
xml.append("<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n");
xml.append(" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n");
xml.append(" xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0\n");
xml.append(" http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n");
xml.append(" <modelVersion>4.0.0</modelVersion>\n\n");
xml.append(" <groupId>").append(groupId).append("</groupId>\n");
xml.append(" <artifactId>").append(artifactId).append("</artifactId>\n");
xml.append(" <version>").append(version).append("</version>\n");
xml.append(" <packaging>pom</packaging>\n\n");
xml.append(" <name>").append(escapeXml(name)).append("</name>\n");
if (description != null) {
xml.append(" <description>")
.append(escapeXml(description.strip()))
.append("</description>\n");
}
xml.append(" <url>").append(url != null ? url : "").append("</url>\n\n");
// Dependency Management
xml.append(" <dependencyManagement>\n");
xml.append(" <dependencies>\n");
for (BomEntry entry : entries) {
xml.append(" <dependency>\n");
xml.append(" <groupId>").append(entry.groupId()).append("</groupId>\n");
xml.append(" <artifactId>").append(entry.artifactId()).append("</artifactId>\n");
xml.append(" <version>").append(entry.version()).append("</version>\n");
if (entry.classifier() != null && !entry.classifier().isEmpty()) {
xml.append(" <classifier>").append(entry.classifier()).append("</classifier>\n");
}
if (entry.type() != null && !"jar".equals(entry.type())) {
xml.append(" <type>").append(entry.type()).append("</type>\n");
}
if (entry.scope() != null && !"compile".equals(entry.scope())) {
xml.append(" <scope>").append(entry.scope()).append("</scope>\n");
}
xml.append(" </dependency>\n");
}
xml.append(" </dependencies>\n");
xml.append(" </dependencyManagement>\n");
xml.append("</project>\n");
return xml.toString();
}
/**
* Escape XML special characters in text content.
*
* @param text input text (may be null)
* @return escaped text, or empty string if null
*/
public static String escapeXml(String text) {
if (text == null) return "";
return text.replace("&", "&")
.replace("<", "<")
.replace(">", ">");
}
}