BuiltWithMojo.java
package network.ike.plugin;
import org.apache.maven.api.Project;
import org.apache.maven.api.di.Inject;
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.yaml.snakeyaml.Yaml;
import java.io.File;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Render a "Built With" page from the CycloneDX SBOM and a curated
* narrative supplement (ike-issues#336).
*
* <p>The user-facing rename of "Third-Party Notices" — friendlier
* scannable name with the same content shape: per-module mechanical
* inventory + project-wide curated supplement.
*
* <p>Sources, in priority order:
* <ol>
* <li><strong>Per-module mechanical</strong>: reads
* {@code target/bom.json} (CycloneDX, produced at
* {@code package} per ike-issues#333) and lists this module's
* direct dependencies with SPDX licenses.</li>
* <li><strong>Project-wide curated supplement</strong>: reads
* {@code src/main/built-with/supplement.yaml} from the
* project root, and if not found, walks up the filesystem
* to find one at a parent reactor. This is where curated
* narrative lives — the things mechanical reports can't see
* (Sentry skin, Kroki, fonts inside artifacts, frontend
* assets, external PDF renderers).</li>
* </ol>
*
* <p>The supplement file is a simple YAML schema:
*
* <pre>{@code
* schema: 1
* sections:
* - heading: "Maven Site skin"
* components:
* - name: Sentry Maven Skin
* url: https://github.com/sentrysoftware/maven-skins
* license: Apache-2.0
* role: Provides the rendered HTML chrome.
* }</pre>
*
* <p>Output: {@code target/generated-site/asciidoc/built-with.adoc},
* which Maven Site renders to {@code target/site/built-with.html}
* with the project's skin chrome.
*
* <p>Skip with {@code -Dike.skip.built-with=true}.
*
* <pre>{@code
* mvn package # produces bom.json
* mvn ike:built-with # produces built-with.adoc
* mvn site # renders built-with.html
* }</pre>
*
* @see RenderSpdxLicensesMojo the SPDX-grouped licenses report
* @see GenerateBomMojo the IKE-specific BOM (different
* concept — Maven dependency BOM)
*/
@Mojo(name = "built-with", defaultPhase = "pre-site")
public class BuiltWithMojo 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; }
/**
* Path to the CycloneDX SBOM produced by
* {@code cyclonedx-maven-plugin} at {@code package} phase
* (ike-issues#333). Absent → log a warning and skip.
*/
@Parameter(property = "ike.bom.path",
defaultValue = "${project.build.directory}/bom.json")
File bomPath;
/**
* Path to the curated narrative supplement YAML file. Default
* is {@code src/main/built-with/supplement.yaml} at the
* current module's basedir. The resolver tries three locations
* in order:
* <ol>
* <li>This path verbatim (per-project local override).</li>
* <li>Walk up from project basedir looking for the same path
* (per-reactor supplement at the workspace root).</li>
* <li>{@link #unpackedSupplementPath} — fallback to the
* platform-wide supplement unpacked from the
* {@code ike-build-standards:built-with:zip} classifier
* (#340). External consumers get this for free without
* authoring their own supplement.</li>
* </ol>
* Absent in all three → the mechanical-only page is rendered
* (still useful per-module).
*/
@Parameter(property = "ike.built-with.supplement",
defaultValue = "${project.basedir}/src/main/built-with/supplement.yaml")
File supplementPath;
/**
* Path to the platform-wide supplement unpacked from the
* {@code ike-build-standards:built-with:zip} classifier (#340).
* Used as the third-priority fallback after the per-project and
* walk-up locations. Defaults to
* {@code target/built-with-supplement.yaml}, matching where
* {@code maven-dependency-plugin}'s
* {@code unpack-built-with-supplement} execution drops the
* unpacked file at pre-site phase.
*/
@Parameter(property = "ike.built-with.unpacked-supplement",
defaultValue = "${project.build.directory}/supplement.yaml")
File unpackedSupplementPath;
/**
* Output AsciiDoc file. Default is the standard generated-site
* location which Maven Site picks up at site:site time and
* renders to {@code target/site/built-with.html}.
*/
@Parameter(property = "ike.built-with.output",
defaultValue = "${project.build.directory}/generated-site/asciidoc/built-with.adoc")
File outputPath;
/** Skip generation. */
@Parameter(property = "ike.skip.built-with", defaultValue = "false")
boolean skip;
/** Project artifact ID, used in the rendered page title. */
@Parameter(defaultValue = "${project.artifactId}", readonly = true)
String projectArtifactId;
/** Project version, used in the rendered page title. */
@Parameter(defaultValue = "${project.version}", readonly = true)
String projectVersion;
/**
* The current Maven project — used to find this module's basedir
* for walking up to a parent reactor's supplement.yaml.
*/
@Inject
private Project project;
/** Creates this goal instance. */
public BuiltWithMojo() {}
@Override
public void execute() throws MojoException {
if (skip) {
getLog().info("ike:built-with skipped "
+ "(-Dike.skip.built-with=true)");
return;
}
if (!bomPath.exists()) {
getLog().warn("Skipping ike:built-with: SBOM not found at "
+ bomPath + ". Run 'mvn package' first to produce "
+ "the SBOM (ike-issues#333).");
return;
}
// Resolve the supplement: prefer per-module path, fall back
// to walking up to a parent reactor's supplement (project-
// wide curated content shared by all modules).
File effectiveSupplement = resolveSupplement();
Map<String, List<Component>> grouped;
try {
grouped = parseAndGroupBom(bomPath);
} catch (IOException e) {
throw new MojoException(
"Could not parse SBOM at " + bomPath + ": "
+ e.getMessage(), e);
}
Supplement supplement = null;
if (effectiveSupplement != null) {
try {
supplement = parseSupplement(effectiveSupplement);
getLog().info(" Using curated supplement from "
+ effectiveSupplement);
} catch (IOException e) {
getLog().warn(" Could not parse supplement at "
+ effectiveSupplement + ": " + e.getMessage()
+ ". Continuing with mechanical-only content.");
}
} else {
getLog().info(" No supplement.yaml found — rendering "
+ "mechanical-only built-with.adoc. To add curated "
+ "narrative, create src/main/built-with/supplement.yaml "
+ "at the reactor root.");
}
String adoc = renderAdoc(grouped, supplement);
File parent = outputPath.getParentFile();
if (parent != null && !parent.isDirectory() && !parent.mkdirs()) {
throw new MojoException(
"Could not create output directory: " + parent);
}
try {
Files.writeString(outputPath.toPath(), adoc,
StandardCharsets.UTF_8);
} catch (IOException e) {
throw new MojoException(
"Could not write " + outputPath + ": " + e.getMessage(),
e);
}
getLog().info("Wrote built-with page: " + outputPath);
getLog().info(" Mechanical: "
+ grouped.values().stream().mapToInt(List::size).sum()
+ " components in "
+ grouped.size() + " license groups");
if (supplement != null) {
getLog().info(" Curated: " + supplement.sections.size()
+ " supplement sections");
}
}
/**
* Find the supplement file by walking up from the project basedir.
* Returns the parameter path if it exists, otherwise walks up
* through ancestor directories looking for the same relative
* path (typically a reactor root).
*
* @return the resolved supplement file, or {@code null} if not found
*/
private File resolveSupplement() {
// (1) Per-project local override.
if (supplementPath.exists() && supplementPath.isFile()) {
return supplementPath;
}
// (2) Walk up from project basedir looking for the same
// relative path — this is how submodules pick up the
// reactor's supplement.yaml without per-module configuration.
Path basedir = project.getBasedir();
Path relativeFromBasedir = basedir.relativize(supplementPath.toPath());
Path parent = basedir.getParent();
while (parent != null) {
Path candidate = parent.resolve(relativeFromBasedir);
if (Files.isRegularFile(candidate)) {
return candidate.toFile();
}
parent = parent.getParent();
}
// (3) Platform-wide fallback (ike-issues#340): the supplement
// unpacked from the ike-build-standards:built-with:zip
// classifier. This is what gives external consumers the
// Curated narrative section without authoring their own
// supplement.yaml.
if (unpackedSupplementPath.exists() && unpackedSupplementPath.isFile()) {
return unpackedSupplementPath;
}
return null;
}
/**
* Parse the SBOM JSON and group components by SPDX expression.
* Same logic as RenderSpdxLicensesMojo's grouping; duplicated
* here to keep the mojos independent.
*
* @param bom path to {@code bom.json}
* @return map from SPDX expression to component list
* @throws IOException if the file cannot be read or parsed
*/
@SuppressWarnings("unchecked")
private Map<String, List<Component>> parseAndGroupBom(File bom)
throws IOException {
Yaml yaml = new Yaml();
Map<String, Object> root;
try (Reader reader = Files.newBufferedReader(bom.toPath(),
StandardCharsets.UTF_8)) {
root = (Map<String, Object>) yaml.load(reader);
}
if (root == null) return new LinkedHashMap<>();
List<Map<String, Object>> components =
(List<Map<String, Object>>) root.get("components");
if (components == null) return new LinkedHashMap<>();
Map<String, List<Component>> grouped = new LinkedHashMap<>();
for (Map<String, Object> c : components) {
String spdx = extractSpdxExpression(c);
grouped.computeIfAbsent(spdx, k -> new ArrayList<>())
.add(new Component(
stringOrEmpty(c.get("group")),
stringOrEmpty(c.get("name")),
stringOrEmpty(c.get("version")),
stringOrEmpty(c.get("description"))));
}
return grouped;
}
@SuppressWarnings("unchecked")
private String extractSpdxExpression(Map<String, Object> c) {
Object licensesObj = c.get("licenses");
if (!(licensesObj instanceof List<?> raw) || raw.isEmpty()) {
return "(no license declared)";
}
List<Map<String, Object>> entries = (List<Map<String, Object>>) raw;
if (entries.size() == 1) {
Object exp = entries.get(0).get("expression");
if (exp != null) return exp.toString();
}
List<String> ids = new ArrayList<>();
for (Map<String, Object> entry : entries) {
Map<String, Object> license =
(Map<String, Object>) entry.get("license");
if (license == null) continue;
Object id = license.get("id");
Object name = license.get("name");
if (id != null) ids.add(id.toString());
else if (name != null) ids.add(name.toString());
}
if (ids.isEmpty()) return "(no license declared)";
if (ids.size() == 1) return ids.get(0);
Collections.sort(ids);
return String.join(" OR ", ids);
}
/**
* Parse the supplement YAML.
*
* @param file the supplement file
* @return the parsed supplement
* @throws IOException if the file cannot be read
*/
@SuppressWarnings("unchecked")
private Supplement parseSupplement(File file) throws IOException {
Yaml yaml = new Yaml();
Map<String, Object> root;
try (Reader reader = Files.newBufferedReader(file.toPath(),
StandardCharsets.UTF_8)) {
root = (Map<String, Object>) yaml.load(reader);
}
if (root == null) {
return new Supplement(new ArrayList<>());
}
List<SupplementSection> sections = new ArrayList<>();
Object sectionsObj = root.get("sections");
if (sectionsObj instanceof List<?> rawSections) {
for (Object sec : rawSections) {
if (!(sec instanceof Map<?, ?> rawSec)) continue;
Map<String, Object> secMap = (Map<String, Object>) rawSec;
String heading = stringOrEmpty(secMap.get("heading"));
List<SupplementComponent> comps = new ArrayList<>();
Object compsObj = secMap.get("components");
if (compsObj instanceof List<?> rawComps) {
for (Object comp : rawComps) {
if (!(comp instanceof Map<?, ?> rawComp)) continue;
Map<String, Object> compMap =
(Map<String, Object>) rawComp;
comps.add(new SupplementComponent(
stringOrEmpty(compMap.get("name")),
stringOrEmpty(compMap.get("url")),
stringOrEmpty(compMap.get("license")),
stringOrEmpty(compMap.get("role"))));
}
}
sections.add(new SupplementSection(heading, comps));
}
}
return new Supplement(sections);
}
/**
* Render the built-with page to AsciiDoc.
*
* @param grouped SBOM components by SPDX expression
* @param supplement curated narrative (may be null)
* @return AsciiDoc page source
*/
private String renderAdoc(Map<String, List<Component>> grouped,
Supplement supplement) {
StringBuilder sb = new StringBuilder();
sb.append("= Built With\n");
sb.append(":icons: font\n");
sb.append(":source-highlighter: coderay\n");
sb.append(":toc: preamble\n");
sb.append(":toclevels: 2\n\n");
sb.append("Open-source software that `")
.append(projectArtifactId).append("` ")
.append(projectVersion)
.append(" depends on, links against, ships within, "
+ "or invokes at runtime.\n\n");
sb.append("Three layers of attribution ship with each release:\n\n");
sb.append("* link:bom.json[Software Bill of Materials "
+ "(CycloneDX, JSON)] — full transitive dependency "
+ "graph with SPDX-normalized licenses and artifact "
+ "hashes. Ingestible by Dependency-Track, Trivy, "
+ "Snyk, GitHub's dependency graph.\n");
sb.append("* link:licenses.html[Licenses (SPDX)] — "
+ "human-readable SPDX-grouped view of declared "
+ "dependencies, generated from `bom.json` (#335).\n");
sb.append("* This page — curated companion covering what "
+ "mechanical reports can't see (Maven Site skin, "
+ "external services, fonts inside artifacts, "
+ "frontend assets in rendered HTML).\n\n");
// Curated narrative (if supplement.yaml provided)
if (supplement != null && !supplement.sections.isEmpty()) {
sb.append("== Curated narrative\n\n");
sb.append("Components covered by the project-wide "
+ "supplement at `src/main/built-with/supplement.yaml`. "
+ "These are the components that don't appear in "
+ "`bom.json` because they aren't Maven artifacts "
+ "(external services, fonts inside classifier "
+ "ZIPs, runtime binaries, frontend assets).\n\n");
for (SupplementSection section : supplement.sections) {
sb.append("=== ").append(section.heading).append("\n\n");
if (section.components.isEmpty()) continue;
sb.append("|===\n");
sb.append("| Component | License | Role\n\n");
for (SupplementComponent c : section.components) {
if (!c.url.isEmpty()) {
sb.append("| ").append(c.url).append("[")
.append(escapeAdoc(c.name)).append("]\n");
} else {
sb.append("| ").append(escapeAdoc(c.name)).append("\n");
}
if (!c.license.isEmpty()) {
sb.append("| `").append(escapeAdoc(c.license))
.append("`\n");
} else {
sb.append("| _(no license declared)_\n");
}
sb.append("| ").append(escapeAdoc(c.role)).append("\n\n");
}
sb.append("|===\n\n");
}
}
// Mechanical inventory
sb.append("== Mechanical inventory\n\n");
sb.append("Direct dependencies of this module, grouped by SPDX "
+ "expression. Generated from `bom.json` at build time.\n\n");
if (grouped.isEmpty()) {
sb.append("_No declared dependencies in this module's SBOM._\n\n");
} else {
sb.append("|===\n");
sb.append("| SPDX Expression | Components\n\n");
int total = 0;
for (Map.Entry<String, List<Component>> g : grouped.entrySet()) {
String spdx = g.getKey();
String formatted = (spdx.startsWith("(") && spdx.endsWith(")"))
? "_" + spdx + "_"
: "`" + spdx + "`";
sb.append("| ").append(formatted).append("\n");
sb.append("| ").append(g.getValue().size()).append("\n\n");
total += g.getValue().size();
}
sb.append("| *Total* | *").append(total).append("*\n");
sb.append("|===\n\n");
sb.append("For full per-component detail (group, artifact, "
+ "version, hashes, transitive deps), see "
+ "link:bom.json[bom.json] or "
+ "link:licenses.html[licenses.html].\n\n");
}
sb.append("== Related\n\n");
sb.append("* link:index.html[site index]\n");
sb.append("* https://github.com/IKE-Network/ike-issues/issues/336"
+ "[ike-issues#336] — the issue that introduced this "
+ "page (rename of the legacy \"Third-Party Notices\" "
+ "to friendlier \"Built With\").\n");
return sb.toString();
}
/**
* Escape a string for inline-code use in AsciiDoc.
*
* @param s input
* @return escaped string
*/
private String escapeAdoc(String s) {
if (s == null) return "";
return s.replace("`", "\\`");
}
/**
* Coerce a possibly-null map value to a non-null string.
*
* @param o the value
* @return the string value, or empty if null
*/
private static String stringOrEmpty(Object o) {
return o == null ? "" : o.toString();
}
/** A single component projected from the SBOM. */
private record Component(String group, String name, String version,
String description) { }
/** Parsed supplement.yaml. */
private record Supplement(List<SupplementSection> sections) { }
/** A heading + list of components from supplement.yaml. */
private record SupplementSection(String heading,
List<SupplementComponent> components) { }
/** A single curated component entry. */
private record SupplementComponent(String name, String url,
String license, String role) { }
}