PomEdgeDeriver.java

package network.ike.workspace.cascade;

import org.apache.maven.api.model.Build;
import org.apache.maven.api.model.Dependency;
import org.apache.maven.api.model.DependencyManagement;
import org.apache.maven.api.model.Model;
import org.apache.maven.api.model.Parent;
import org.apache.maven.api.model.Plugin;
import org.apache.maven.api.model.PluginManagement;

import javax.xml.namespace.QName;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamConstants;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;
import java.io.IOException;
import java.io.Reader;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

/**
 * Derives the {@link CascadeEdge}s a project radiates upstream from
 * its Maven model and on-disk layout (IKE-Network/ike-issues#496
 * part B).
 *
 * <p>Replaces the hand-authored {@code release-cascade.yaml} as the
 * source of an IKE project's upstream edges. The cascade specifies
 * every version-bearing site in an IKE POM as a potential edge, not
 * just {@code <dependencies>}: parent inheritance, plain
 * dependencies, dependency-management entries (including imported
 * BOMs), plugins, plugin management, and {@code .mvn/extensions.xml}.
 * Under the {@code ${G·A}} property convention the property name an
 * alignment step rewrites is mechanical
 * ({@link CascadeEdge#versionProperty()}), so no manifest field
 * declares it — the coordinate is enough.
 *
 * <p>The deriver emits an edge only for coordinates a
 * {@link CoordinateFilter} accepts; the default filter
 * ({@link CoordinateFilter#IKE_GROUP}) keeps edges whose
 * {@code groupId} starts with {@code network.ike}. Third-party
 * dependencies stay out of the graph: IKE does not release them, so
 * they have no place in a release ordering.
 *
 * <p>A coordinate must bear a {@code <version>} at its site to count.
 * A {@code <dependency>} that inherits its version from
 * {@code <dependencyManagement>} contributes no edge from the
 * dependency site itself; the contributing edge sits at the
 * {@code <dependencyManagement>} entry instead.
 *
 * <p>Self-edges — where a coordinate's reactor-root repository is the
 * same repository as the POM the deriver is scanning — are <em>not</em>
 * filtered here. The deriver knows the POM's coordinates but not its
 * {@code <scm>}, and the same repository can publish many coordinates.
 * Self-edge filtering happens after the {@code <scm>}-keyed node
 * resolution in IKE-Network/ike-issues#496 part D.
 */
public final class PomEdgeDeriver {

    /**
     * A predicate over a {@link MavenCoordinate} that selects which
     * edges the deriver should emit. {@link #IKE_GROUP} is the IKE
     * default.
     */
    @FunctionalInterface
    public interface CoordinateFilter {

        /**
         * Tests whether a coordinate should produce an edge.
         *
         * @param coordinate the coordinate; never {@code null}
         * @return {@code true} iff an edge should be emitted
         */
        boolean accepts(MavenCoordinate coordinate);

        /**
         * Keeps coordinates whose {@code groupId} starts with
         * {@code "network.ike"}. The conventional filter for IKE
         * Network projects.
         */
        CoordinateFilter IKE_GROUP = coordinate ->
                coordinate.groupId().startsWith("network.ike");
    }

    /** Conventional path of a Maven 4 build-extensions descriptor. */
    public static final String EXTENSIONS_RELATIVE_PATH =
            ".mvn/extensions.xml";

    private static final QName EXTENSION_QNAME =
            new QName("extension");
    private static final QName GROUP_ID_QNAME = new QName("groupId");
    private static final QName ARTIFACT_ID_QNAME =
            new QName("artifactId");
    private static final QName VERSION_QNAME = new QName("version");

    private PomEdgeDeriver() {}

    /**
     * Derives the upstream edges of a project from its model and
     * project directory.
     *
     * @param model      the project's Maven model (typically the file
     *                   model, but any stage works — the deriver only
     *                   reads structural fields)
     * @param projectDir the project's on-disk root directory, used to
     *                   locate {@code .mvn/extensions.xml}; may be
     *                   {@code null} if the caller knows the project
     *                   has no extensions descriptor
     * @return the derived upstream edges, in the order the sites
     *         appear in the POM (parent, dependencies, depMgmt,
     *         plugins, pluginMgmt, extensions); never {@code null}
     */
    public static List<CascadeEdge> deriveEdges(Model model,
                                                Path projectDir) {
        return deriveEdges(model, projectDir, CoordinateFilter.IKE_GROUP);
    }

    /**
     * Derives upstream edges with a caller-supplied coordinate
     * filter.
     *
     * @param model      the project's Maven model
     * @param projectDir the project's on-disk root directory; may be
     *                   {@code null}
     * @param filter     selects which coordinates produce edges; must
     *                   not be {@code null}
     * @return the derived upstream edges, in source-order; never
     *         {@code null}
     */
    public static List<CascadeEdge> deriveEdges(Model model,
                                                Path projectDir,
                                                CoordinateFilter filter) {
        if (model == null) {
            throw new IllegalArgumentException("model is required");
        }
        if (filter == null) {
            throw new IllegalArgumentException("filter is required");
        }
        List<CascadeEdge> edges = new ArrayList<>();
        appendParentEdge(model, filter, edges);
        appendDependencyEdges(model, filter, edges);
        appendDependencyManagementEdges(model, filter, edges);
        appendPluginEdges(model, filter, edges);
        appendExtensionEdges(projectDir, filter, edges);
        return List.copyOf(edges);
    }

    /**
     * Derives upstream edges and drops self-edges — edges whose
     * target repository, as resolved by {@code repositoryResolver},
     * equals {@code sourceRepo} (IKE-Network/ike-issues#496 part D).
     *
     * <p>Self-edges are reactor-internal: a project using its own
     * sibling artifact ({@code ike-tooling} consuming
     * {@code ike-maven-plugin}, {@code ike-platform} consuming
     * {@code ike-workspace-maven-plugin}) is a relationship Maven
     * resolves inside the one reactor build, not a cascade edge.
     * Dropping them keeps the topological sort a DAG.
     *
     * <p>An edge whose target the resolver cannot locate is kept
     * conservatively — without information, the deriver does not
     * silently filter it out. Callers can chase the unresolved
     * coordinate themselves.
     *
     * @param model              the project's Maven model
     * @param projectDir         the project's on-disk root directory;
     *                           may be {@code null}
     * @param filter             coordinate filter; required
     * @param sourceRepo         the source POM's
     *                           {@link RepositoryKey}; when
     *                           {@code null}, no self-edge filtering
     *                           is performed
     * @param repositoryResolver maps an edge's target coordinate to
     *                           its {@link RepositoryKey}; when
     *                           {@code null}, no self-edge filtering
     *                           is performed
     * @return external upstream edges in source-order; never
     *         {@code null}
     */
    public static List<CascadeEdge> deriveEdges(Model model,
                                                Path projectDir,
                                                CoordinateFilter filter,
                                                RepositoryKey sourceRepo,
                                                RepositoryKeyResolver repositoryResolver) {
        List<CascadeEdge> edges = deriveEdges(model, projectDir, filter);
        if (sourceRepo == null || repositoryResolver == null) {
            return edges;
        }
        List<CascadeEdge> kept = new ArrayList<>();
        for (CascadeEdge edge : edges) {
            RepositoryKey target = repositoryResolver
                    .resolve(edge.coordinate())
                    .orElse(null);
            if (target == null || !target.equals(sourceRepo)) {
                kept.add(edge);
            }
        }
        return List.copyOf(kept);
    }

    private static void appendParentEdge(Model model,
                                          CoordinateFilter filter,
                                          List<CascadeEdge> out) {
        Parent parent = model.getParent();
        if (parent == null) {
            return;
        }
        // Maven enforces a version on <parent>; a null/blank groupId
        // or artifactId here would be a malformed POM and the model
        // layer would have rejected it.
        MavenCoordinate.tryOf(parent.getGroupId(), parent.getArtifactId())
                .filter(filter::accepts)
                .ifPresent(coord -> out.add(edge(coord, EdgeKind.PARENT)));
    }

    private static void appendDependencyEdges(Model model,
                                               CoordinateFilter filter,
                                               List<CascadeEdge> out) {
        List<Dependency> deps = model.getDependencies();
        if (deps == null) {
            return;
        }
        for (Dependency dep : deps) {
            if (!hasVersion(dep.getVersion())) {
                // Inherits its version from <dependencyManagement>;
                // the contributing edge is at the depMgmt site, not
                // here.
                continue;
            }
            MavenCoordinate.tryOf(dep.getGroupId(), dep.getArtifactId())
                    .filter(filter::accepts)
                    .ifPresent(coord -> out.add(
                            edge(coord, EdgeKind.DEPENDENCY)));
        }
    }

    private static void appendDependencyManagementEdges(
            Model model, CoordinateFilter filter,
            List<CascadeEdge> out) {
        DependencyManagement dm = model.getDependencyManagement();
        if (dm == null || dm.getDependencies() == null) {
            return;
        }
        for (Dependency dep : dm.getDependencies()) {
            if (!hasVersion(dep.getVersion())) {
                continue;
            }
            EdgeKind kind = "import".equalsIgnoreCase(dep.getScope())
                    ? EdgeKind.BOM
                    : EdgeKind.DEPENDENCY;
            MavenCoordinate.tryOf(dep.getGroupId(), dep.getArtifactId())
                    .filter(filter::accepts)
                    .ifPresent(coord -> out.add(edge(coord, kind)));
        }
    }

    private static void appendPluginEdges(Model model,
                                           CoordinateFilter filter,
                                           List<CascadeEdge> out) {
        Build build = model.getBuild();
        if (build == null) {
            return;
        }
        appendPlugins(build.getPlugins(), filter, out);
        PluginManagement pm = build.getPluginManagement();
        if (pm != null) {
            appendPlugins(pm.getPlugins(), filter, out);
        }
    }

    private static void appendPlugins(List<Plugin> plugins,
                                       CoordinateFilter filter,
                                       List<CascadeEdge> out) {
        if (plugins == null) {
            return;
        }
        for (Plugin plugin : plugins) {
            if (!hasVersion(plugin.getVersion())) {
                continue;
            }
            // A plugin's groupId can default to
            // org.apache.maven.plugins when absent; tryOf returns
            // empty for null/blank so the deriver skips those
            // implicit-group entries (third-party, not on the IKE
            // cascade).
            MavenCoordinate.tryOf(plugin.getGroupId(),
                            plugin.getArtifactId())
                    .filter(filter::accepts)
                    .ifPresent(coord -> out.add(
                            edge(coord, EdgeKind.PLUGIN)));
        }
    }

    private static void appendExtensionEdges(Path projectDir,
                                              CoordinateFilter filter,
                                              List<CascadeEdge> out) {
        if (projectDir == null) {
            return;
        }
        Path extensionsXml = projectDir.resolve(EXTENSIONS_RELATIVE_PATH);
        if (!Files.isRegularFile(extensionsXml)) {
            return;
        }
        try (Reader reader = Files.newBufferedReader(
                extensionsXml, StandardCharsets.UTF_8)) {
            readExtensions(reader, filter, out);
        } catch (IOException e) {
            throw new UncheckedIOException(
                    "Cannot read " + extensionsXml, e);
        } catch (XMLStreamException e) {
            throw new IllegalStateException(
                    "Malformed " + extensionsXml + ": "
                    + e.getMessage(), e);
        }
    }

    private static void readExtensions(Reader source,
                                        CoordinateFilter filter,
                                        List<CascadeEdge> out)
            throws XMLStreamException {
        XMLInputFactory factory = XMLInputFactory.newFactory();
        factory.setProperty(XMLInputFactory.IS_NAMESPACE_AWARE,
                Boolean.FALSE);
        factory.setProperty(XMLInputFactory.SUPPORT_DTD,
                Boolean.FALSE);
        factory.setProperty("javax.xml.stream.isSupportingExternalEntities",
                Boolean.FALSE);
        XMLStreamReader xml = factory.createXMLStreamReader(source);
        try {
            String groupId = null;
            String artifactId = null;
            String version = null;
            String currentLeaf = null;
            boolean insideExtension = false;
            StringBuilder leafText = new StringBuilder();

            while (xml.hasNext()) {
                int event = xml.next();
                if (event == XMLStreamConstants.START_ELEMENT) {
                    String name = xml.getLocalName();
                    if (EXTENSION_QNAME.getLocalPart().equals(name)) {
                        insideExtension = true;
                        groupId = null;
                        artifactId = null;
                        version = null;
                    } else if (insideExtension && (
                            GROUP_ID_QNAME.getLocalPart().equals(name)
                            || ARTIFACT_ID_QNAME.getLocalPart().equals(name)
                            || VERSION_QNAME.getLocalPart().equals(name))) {
                        currentLeaf = name;
                        leafText.setLength(0);
                    }
                } else if (event == XMLStreamConstants.CHARACTERS
                        && currentLeaf != null) {
                    leafText.append(xml.getText());
                } else if (event == XMLStreamConstants.END_ELEMENT) {
                    String name = xml.getLocalName();
                    if (currentLeaf != null && currentLeaf.equals(name)) {
                        String value = leafText.toString().trim();
                        switch (currentLeaf) {
                            case "groupId" -> groupId = value;
                            case "artifactId" -> artifactId = value;
                            case "version" -> version = value;
                            default -> { /* ignored */ }
                        }
                        currentLeaf = null;
                    } else if (EXTENSION_QNAME.getLocalPart().equals(name)
                            && insideExtension) {
                        if (hasVersion(version)) {
                            final String g = groupId;
                            final String a = artifactId;
                            MavenCoordinate.tryOf(g, a)
                                    .filter(filter::accepts)
                                    .ifPresent(coord -> out.add(
                                            edge(coord,
                                                    EdgeKind.EXTENSION)));
                        }
                        insideExtension = false;
                    }
                }
            }
        } finally {
            xml.close();
        }
    }

    /**
     * An identity-only edge for a derived upstream — the deriver does
     * not know the upstream's on-disk {@code repo} name or git URL
     * (those come from the {@code <scm>}-keyed node resolution in
     * IKE-Network/ike-issues#496 part C).
     */
    private static CascadeEdge edge(MavenCoordinate coordinate,
                                    EdgeKind kind) {
        return new CascadeEdge(coordinate, null, null, kind);
    }

    /**
     * A version field counts if it is non-null and non-blank. The
     * value need not be a literal; {@code ${G·A}} placeholders
     * count as "version-bearing" because they declare an upstream
     * pin even if interpolation has not happened yet.
     */
    private static boolean hasVersion(String version) {
        return version != null && !version.isBlank();
    }
}