PreflightCondition.java

package network.ike.plugin.ws.preflight;

import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.SnapshotScanner;
import network.ike.plugin.ws.WsGoal;

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.List;
import java.util.Optional;

/**
 * Closed vocabulary of preflight checks that {@code ws:*} goals can
 * require before they mutate workspace state. Each entry declares a
 * human-readable description and a {@link #check(PreflightContext)}
 * implementation that returns {@link Optional#empty()} on success or a
 * remediation message on failure.
 *
 * <p>Drafts and publishes invoke the same {@code PreflightCondition}
 * sequence via {@link Preflight}; whether failure is a warning (draft)
 * or a hard error (publish) is decided at the call site via
 * {@link PreflightResult#requirePassed(WsGoal)} vs
 * {@link PreflightResult#warnIfFailed(Log, WsGoal)}.
 *
 * <p>New conditions are added here as goals adopt the contract from
 * issue #154. Each new entry must stay self-contained: it does not
 * depend on the mojo instance, only on the shared {@link PreflightContext}.
 */
public enum PreflightCondition {

    /**
     * Every subproject working tree (and the workspace root itself, if
     * it is a git repo) must have no uncommitted changes. Any draft or
     * publish goal that creates branches, edits POMs, or otherwise
     * mutates files requires this.
     */
    WORKING_TREE_CLEAN("All subproject working trees are clean") {
        @Override
        public Optional<String> check(PreflightContext ctx) {
            File root = ctx.workspaceRoot();
            List<String> uncommitted = new ArrayList<>();

            if (new File(root, ".git").exists()
                    && !gitStatus(root).isEmpty()) {
                uncommitted.add(WORKSPACE_ROOT_NAME);
            }
            for (String name : ctx.subprojects()) {
                File dir = new File(root, name);
                if (!new File(dir, ".git").exists()) continue;
                if (!gitStatus(dir).isEmpty()) {
                    uncommitted.add(name);
                }
            }

            if (uncommitted.isEmpty()) return Optional.empty();

            var sb = new StringBuilder();
            sb.append(uncommitted.size())
                    .append(" subproject(s) have uncommitted changes:\n");
            boolean anyGhPagesLeak = false;
            for (String name : uncommitted) {
                File dir = WORKSPACE_ROOT_NAME.equals(name)
                        ? root : new File(root, name);
                String status = gitStatus(dir);
                if (containsGhPagesLeakPattern(status)) {
                    anyGhPagesLeak = true;
                }
                String files = status.lines()
                        .map(l -> "      " + l.strip())
                        .reduce((a, b) -> a + "\n" + b)
                        .orElse("");
                sb.append("    • ").append(name).append(":\n")
                  .append(files).append("\n");
            }
            sb.append("  To resolve:\n");
            sb.append("    mvn ws:commit"
                    + " -Dmessage=\"<your message>\"\n");
            sb.append("  Or stash changes in each affected subproject.");
            if (anyGhPagesLeak) {
                sb.append("\n");
                sb.append("  ⚠ Some entries above match gh-pages-style site\n");
                sb.append("    output (paths like <artifactId>/<artifactId>/\n");
                sb.append("    or examples/<name>/, containing bom.json,\n");
                sb.append("    built-with.html, dependencies.html, etc.).\n");
                sb.append("    These do NOT belong in main — the canonical\n");
                sb.append("    published content lives on each repo's\n");
                sb.append("    gh-pages branch. Delete them and add\n");
                sb.append("    .gitignore entries to prevent recurrence.\n");
                sb.append("    NEVER use `git add -A` or `git add .` to\n");
                sb.append("    stage cascade bump commits — these sweep\n");
                sb.append("    such leaks into main. Use explicit paths:\n");
                sb.append("    `git add pom.xml`. ike-issues#358.");
            }
            return Optional.of(sb.toString());
        }
    },

    /**
     * No {@code <properties>} entry in any subproject root POM may hold a
     * value ending in {@code -SNAPSHOT}. Maven 4's consumer POM flattener
     * resolves properties and promotes {@code <pluginManagement>} into
     * {@code <plugins>} when writing the released artifact — a SNAPSHOT
     * property value would then be baked in as a literal and break
     * downstream consumers (e.g. {@code <ike-tooling.version>112-SNAPSHOT}
     * leaking into released {@code ike-parent-105.pom}). This check
     * forces the release operator to bump the property to a released
     * version before cutting the release.
     */
    NO_SNAPSHOT_PROPERTIES("No subproject root POM carries -SNAPSHOT property values") {
        @Override
        public Optional<String> check(PreflightContext ctx) {
            File root = ctx.workspaceRoot();
            List<SnapshotScanner.Violation> all = new ArrayList<>();

            for (String name : ctx.subprojects()) {
                File pom = new File(new File(root, name), "pom.xml");
                if (!pom.isFile()) continue;
                all.addAll(SnapshotScanner.scanSourceProperties(pom));
            }

            if (all.isEmpty()) return Optional.empty();

            return Optional.of(SnapshotScanner.formatViolations(all, root,
                    all.size() + " SNAPSHOT property value(s) would leak into"
                            + " released POMs:",
                    "  These values are substituted by Maven 4's consumer POM\n"
                    + "  flattener and baked into released artifacts. Bump each\n"
                    + "  property to a released (non-SNAPSHOT) version before\n"
                    + "  re-running the release."));
        }
    },

    /**
     * No {@code .mvn/jvm.config} file in the workspace root or any
     * subproject may contain a line starting with {@code #}.
     *
     * <p>Maven parses {@code .mvn/jvm.config} as raw JVM arguments —
     * one token per line, with no comment syntax. A {@code #} at
     * column 0 is passed to the JVM launcher as a main-class name and
     * IntelliJ surfaces it as
     * {@code Error: Could not find or load main class #}. The fix is
     * to delete the offending line; comments belong in
     * {@code .mvn/jvm.config.notes} or similar adjacent files.
     *
     * <p>This is the gate referenced in ike-issues#217. The check fires
     * before the bad file can propagate to git or Syncthing — Maven's
     * own {@code validate} phase can't catch this in the project that
     * contains the bad file because the JVM dies before plugin code
     * runs.
     */
    JVM_CONFIG_NO_HASH_COMMENTS(
            "No .mvn/jvm.config file contains a # comment line") {
        @Override
        public Optional<String> check(PreflightContext ctx) {
            File root = ctx.workspaceRoot();
            List<String> violations = new ArrayList<>();

            collectJvmConfigViolations(root, WORKSPACE_ROOT_NAME, violations);
            for (String name : ctx.subprojects()) {
                collectJvmConfigViolations(new File(root, name), name,
                        violations);
            }

            if (violations.isEmpty()) return Optional.empty();

            var sb = new StringBuilder();
            sb.append(violations.size())
                    .append(" .mvn/jvm.config file(s) contain # comment lines:\n");
            for (String v : violations) {
                sb.append("    • ").append(v).append('\n');
            }
            sb.append("  Maven parses .mvn/jvm.config as raw JVM arguments\n");
            sb.append("  — '#' at column 0 is passed to the JVM launcher as a\n");
            sb.append("  main-class name and crashes the build. Delete the\n");
            sb.append("  offending line; put commentary in an adjacent file.");
            return Optional.of(sb.toString());
        }
    },

    /**
     * Every workspace subproject's root POM must either declare
     * {@code <distributionManagement>} locally or have a {@code <parent>}
     * block to inherit from (#346, surfaced by #343 when {@code its/}
     * was missing both).
     *
     * <p>Site goals — specifically {@code site:stage}, which the
     * workspace release cascade runs — fail with
     * <em>"Missing distribution management in project ..."</em> when
     * neither is present. That failure surfaces deep inside the
     * release flow, after some subprojects have already tagged. This
     * preflight catches the missing declaration upfront so the
     * release-draft is authoritative.
     */
    SUBPROJECT_HAS_DISTRIBUTION_MANAGEMENT(
            "Every subproject root POM resolves <distributionManagement>") {
        @Override
        public Optional<String> check(PreflightContext ctx) {
            File root = ctx.workspaceRoot();
            List<String> violations = new ArrayList<>();

            for (String name : ctx.subprojects()) {
                File pom = new File(new File(root, name), "pom.xml");
                if (!pom.isFile()) continue;
                String content;
                try {
                    content = Files.readString(pom.toPath(),
                            StandardCharsets.UTF_8);
                } catch (IOException e) {
                    // Best-effort — preflight does not fail on read errors
                    continue;
                }
                if (!hasDistributionManagementOrParent(content)) {
                    violations.add(name + " (no <distributionManagement> "
                            + "or <parent> in its root POM)");
                }
            }

            if (violations.isEmpty()) return Optional.empty();

            var sb = new StringBuilder();
            sb.append(violations.size())
                    .append(" subproject(s) missing <distributionManagement>:\n");
            for (String v : violations) {
                sb.append("    • ").append(v).append('\n');
            }
            sb.append("  site:stage during the workspace release cascade\n");
            sb.append("  will fail with \"Missing distribution management in\n");
            sb.append("  project ...\". Either declare\n");
            sb.append("  <distributionManagement><site>...</site>... directly\n");
            sb.append("  on the subproject's root POM, or add a <parent>\n");
            sb.append("  block inheriting from one of the IKE parents (e.g.\n");
            sb.append("  network.ike.platform:ike-parent).");
            return Optional.of(sb.toString());
        }
    },

    /**
     * No subproject's {@code <properties>} block may declare a
     * locally-overriding value for any IKE-foundation property name
     * (#346, surfaced by the {@code its/} pom property-shadowing in
     * the v150 cascade).
     *
     * <p>Foundation properties (
     * {@code ike-tooling.version},
     * {@code ike-docs.version},
     * {@code ike-platform.version}) are set by {@code ike-parent}'s
     * inheritance chain. Local overrides silently shadow the
     * workspace's intended versions and pin plugins to old releases
     * that may lack newer goals. Preferred discipline: namespace
     * local overrides under {@code it.*}, {@code local.*}, or a
     * project-specific prefix that doesn't collide with the
     * foundation set.
     */
    NO_FOUNDATION_PROPERTY_SHADOWING(
            "No subproject overrides ike-foundation property names") {
        @Override
        public Optional<String> check(PreflightContext ctx) {
            File root = ctx.workspaceRoot();
            List<String> violations = new ArrayList<>();

            for (String name : ctx.subprojects()) {
                File pom = new File(new File(root, name), "pom.xml");
                if (!pom.isFile()) continue;
                String content;
                try {
                    content = Files.readString(pom.toPath(),
                            StandardCharsets.UTF_8);
                } catch (IOException e) {
                    continue;
                }
                for (String prop : FOUNDATION_PROPERTY_NAMES) {
                    if (shadowsProperty(content, prop)) {
                        violations.add(name + "/pom.xml shadows <" + prop
                                + "> (inherited from ike-parent)");
                    }
                }
            }

            if (violations.isEmpty()) return Optional.empty();

            var sb = new StringBuilder();
            sb.append(violations.size())
                    .append(" IKE-foundation property shadow(s):\n");
            for (String v : violations) {
                sb.append("    • ").append(v).append('\n');
            }
            sb.append("  These property names control plugin resolution for\n");
            sb.append("  every project inheriting ike-parent. Local overrides\n");
            sb.append("  pin plugins to whatever version the subproject\n");
            sb.append("  declares, often pre-dating goals the release cycle\n");
            sb.append("  expects (e.g. ike-tooling 126 lacks #335's\n");
            sb.append("  render-spdx-licenses). Rename the local property to\n");
            sb.append("  an `it.*` / `local.*` namespace and update any\n");
            sb.append("  references to use the new name.");
            return Optional.of(sb.toString());
        }
    },

    /**
     * When a subproject's {@code <parent>} declares the same GA as the
     * workspace aggregator's own {@code <parent>}, enforce the two
     * coherence rules from #324:
     *
     * <ol>
     *   <li>{@code <parent><version>} matches the workspace's.</li>
     *   <li>{@code <parent>} block includes an empty
     *       {@code <relativePath/>} to prevent the Maven 4
     *       parent-cycle error.</li>
     * </ol>
     *
     * <p>This preflight is the release-gate analog of
     * {@code ws:scaffold-draft}'s coherence check (#324; folded
     * from the retired {@code ws:verify} per #393). The scaffold-draft
     * check warns; the preflight blocks. Composed via Preflight so
     * release-draft surfaces it as a warning and release-publish
     * promotes it to a hard error.
     */
    /**
     * No on-disk gh-pages-style site output leaks at
     * {@code <projectDir>/<artifactId>/<artifactId>/index.html} —
     * whether or not git tracks them.
     *
     * <p>Why this exists despite {@link #WORKING_TREE_CLEAN} already
     * detecting committed leaks: once an operator adds
     * {@code .gitignore} entries (the standing workaround in
     * ike-issues#358), the leak files are no longer reported by
     * {@code git status}, so {@link #WORKING_TREE_CLEAN} thinks the
     * tree is clean — but the files keep getting regenerated under
     * the working tree on every release flow. This check scans the
     * filesystem directly so the operator sees the leak even when
     * git has been told to ignore it.
     *
     * <p>Pattern: a directory named exactly the same as a known
     * cascade artifactId living inside another directory named the
     * same, containing an {@code index.html} — signature of an
     * {@code mvn site} render that escaped {@code target/}. We check
     * both the workspace root (where workspace-root and subproject
     * artifactIds can both shadow) and each subproject root.
     * ike-issues#358.
     */
    NO_ON_DISK_GHPAGES_LEAK(
            "No on-disk gh-pages-style site output leaks under any "
                    + "project's working tree") {
        @Override
        public Optional<String> check(PreflightContext ctx) {
            File root = ctx.workspaceRoot();
            List<String> artifactIds = collectCascadeArtifactIds(ctx);
            if (artifactIds.isEmpty()) return Optional.empty();

            List<Path> leaks = new ArrayList<>();

            // Workspace root: any artifactId in the cascade can shadow.
            detectGhPagesLeakDirs(root, artifactIds, leaks);

            // Each subproject root: typically just its own artifactId,
            // but cheap to check the full cascade set in case of
            // cross-shadowing during a multi-module workspace run.
            for (String name : ctx.subprojects()) {
                File sub = new File(root, name);
                if (!sub.isDirectory()) continue;
                detectGhPagesLeakDirs(sub, artifactIds, leaks);
            }

            if (leaks.isEmpty()) return Optional.empty();

            var sb = new StringBuilder();
            sb.append(leaks.size())
                    .append(" on-disk gh-pages leak director")
                    .append(leaks.size() == 1 ? "y" : "ies")
                    .append(" found:\n");
            for (Path leak : leaks) {
                Path rel;
                try {
                    rel = root.toPath().relativize(leak);
                } catch (IllegalArgumentException ignore) {
                    rel = leak;
                }
                sb.append("    • ").append(rel).append('\n');
            }
            sb.append("  These directories hold rendered Maven Site\n");
            sb.append("  output (index.html + css/ + images/) that\n");
            sb.append("  escaped target/ during a release-flow\n");
            sb.append("  `mvn site:stage` invocation. .gitignore may\n");
            sb.append("  already block them from being committed, but\n");
            sb.append("  the files keep coming back. The underlying\n");
            sb.append("  cause was fixed in ike-platform's ike-parent\n");
            sb.append("  (see ike-issues#358 closing comment); when the\n");
            sb.append("  next ike-platform release ships and this\n");
            sb.append("  workspace bumps to it, the leak stops at the\n");
            sb.append("  source. Until then, safe to delete the\n");
            sb.append("  directories above — canonical published\n");
            sb.append("  content lives on each repo's gh-pages branch.\n");
            sb.append("  Copy-paste cleanup:\n");
            for (Path leak : leaks) {
                Path rel;
                try {
                    rel = root.toPath().relativize(leak);
                } catch (IllegalArgumentException ignore) {
                    rel = leak;
                }
                // Strip the trailing /<artifactId>/<artifactId> down to
                // /<artifactId> so the rm wipes the whole top-level
                // leak dir (which has nothing legitimate in it). For
                // workspace-root leaks the path is <artifactId>/; for
                // subproject leaks it's <subprojectDir>/<artifactId>/.
                Path parent = rel.getParent();
                if (parent != null) {
                    sb.append("    rm -rf ").append(parent).append('\n');
                } else {
                    sb.append("    rm -rf ").append(rel).append('\n');
                }
            }
            sb.append("  ike-issues#358.");
            return Optional.of(sb.toString());
        }
    },

    /**
     * No subproject root POM (nor the workspace root) declares a
     * {@code <distributionManagement><site><url>} starting with
     * {@code scpexe://} — that wagon was retired in
     * ike-issues#304 in favor of the GitHub Pages publish path
     * ({@code https://ike.network/<repo>/} via the org CNAME).
     *
     * <p>A surviving {@code scpexe://} is a silent release-blocker:
     * the goal that consumes the URL only fails at the step that
     * tries to use the wagon, after subproject releases have
     * already started. This check catches it at draft time so the
     * operator fixes the URL before any tags ship.
     *
     * <p>ike-issues#372.
     */
    NO_SCPEXE_SITE_URLS(
            "No POM declares a scpexe:// <site><url> "
                    + "(retired in #304)") {
        @Override
        public Optional<String> check(PreflightContext ctx) {
            File root = ctx.workspaceRoot();
            List<String> violations = new ArrayList<>();

            scanPomForScpexeSiteUrl(new File(root, "pom.xml"),
                    "workspace root", violations);
            for (String name : ctx.subprojects()) {
                scanPomForScpexeSiteUrl(
                        new File(new File(root, name), "pom.xml"),
                        name, violations);
            }

            if (violations.isEmpty()) return Optional.empty();

            var sb = new StringBuilder();
            sb.append(violations.size())
                    .append(" POM(s) declare a scpexe:// <site><url>:\n");
            for (String v : violations) {
                sb.append("    • ").append(v).append('\n');
            }
            sb.append("  scpexe:// was retired in ike-issues#304.\n");
            sb.append("  Canonical site distribution is now GitHub Pages\n");
            sb.append("  served at https://ike.network/<repo>/ via the\n");
            sb.append("  org CNAME. Update each pom's\n");
            sb.append("  <distributionManagement><site><url> to the\n");
            sb.append("  https:// form (inheriting from ike-parent's\n");
            sb.append("  https://ike.network/${project.artifactId}/\n");
            sb.append("  default usually suffices — delete the override).\n");
            sb.append("  ike-issues#372.");
            return Optional.of(sb.toString());
        }
    },

    PARENT_COHERENCE(
            "Subprojects sharing the workspace's parent GA have "
                    + "matching version + <relativePath/>") {
        @Override
        public Optional<String> check(PreflightContext ctx) {
            File root = ctx.workspaceRoot();
            File workspacePom = new File(root, "pom.xml");
            if (!workspacePom.isFile()) return Optional.empty();

            String workspaceContent;
            try {
                workspaceContent = Files.readString(workspacePom.toPath(),
                        StandardCharsets.UTF_8);
            } catch (IOException e) {
                return Optional.empty();
            }

            String wsParentGA = extractParentGa(workspaceContent);
            String wsParentVersion = extractParentVersion(workspaceContent);
            if (wsParentGA == null) return Optional.empty();

            List<String> violations = new ArrayList<>();

            for (String name : ctx.subprojects()) {
                File pom = new File(new File(root, name), "pom.xml");
                if (!pom.isFile()) continue;
                String content;
                try {
                    content = Files.readString(pom.toPath(),
                            StandardCharsets.UTF_8);
                } catch (IOException e) {
                    continue;
                }
                String subParentGA = extractParentGa(content);
                String subParentVersion = extractParentVersion(content);
                if (subParentGA == null
                        || !subParentGA.equals(wsParentGA)) {
                    continue; // out of scope — different parent
                }

                if (wsParentVersion != null
                        && !wsParentVersion.equals(subParentVersion)) {
                    violations.add(name + " parent " + subParentGA + ":"
                            + subParentVersion + " != workspace " + wsParentGA
                            + ":" + wsParentVersion + " (#324)");
                    continue;
                }

                if (!network.ike.plugin.ws.PomParentSupport
                        .hasEmptyRelativePathInContent(content)) {
                    violations.add(name + " parent " + subParentGA + ":"
                            + subParentVersion + " missing empty "
                            + "<relativePath/> (#324 cycle prevention)");
                }
            }

            if (violations.isEmpty()) return Optional.empty();

            var sb = new StringBuilder();
            sb.append(violations.size())
                    .append(" parent-coherence violation(s):\n");
            for (String v : violations) {
                sb.append("    • ").append(v).append('\n');
            }
            sb.append("  Subprojects sharing the workspace's parent GA must\n");
            sb.append("  agree on parent version AND declare an empty\n");
            sb.append("  <relativePath/> to prevent the Maven 4\n");
            sb.append("  \"parents form a cycle\" error. ws:scaffold-draft\n");
            sb.append("  reports the same coherence check as a warning (folded\n");
            sb.append("  from the retired ws:verify per #393); the release gate\n");
            sb.append("  promotes it to a hard requirement.");
            return Optional.of(sb.toString());
        }
    };

    /** Special marker used when the workspace root itself has uncommitted changes. */
    public static final String WORKSPACE_ROOT_NAME = "workspace root";

    private final String description;

    PreflightCondition(String description) {
        this.description = description;
    }

    /** Short human description of what this condition enforces. */
    public String description() {
        return description;
    }

    /**
     * Evaluate the condition against the given context.
     *
     * @param ctx the preflight context
     * @return {@link Optional#empty()} if the condition is satisfied;
     *         a remediation message otherwise
     */
    public abstract Optional<String> check(PreflightContext ctx);

    // ── Shared helpers ──────────────────────────────────────────────

    static String gitStatus(File dir) {
        try {
            return ReleaseSupport.execCapture(dir,
                    "git", "status", "--porcelain").trim();
        } catch (MojoException e) {
            return "";
        }
    }

    /**
     * Return {@code true} when the given {@code git status --porcelain}
     * output mentions paths matching a gh-pages-style site output
     * layout: {@code <something>/<same>/<site files>} doubling, or
     * {@code examples/<name>/<site files>}. The signal files we look
     * for ({@code bom.json}, {@code built-with.html},
     * {@code dependencies.html}, {@code dependency-management.html},
     * {@code distribution-management.html}, {@code project-info.html})
     * are emitted by maven-project-info-reports-plugin during
     * site:stage and don't belong in source control.
     *
     * <p>Used by {@link #WORKING_TREE_CLEAN} to amplify the diagnostic
     * when an operator is about to bump a workspace whose main tree
     * contains leaked gh-pages content. ike-issues#358.
     *
     * @param porcelain raw {@code git status --porcelain} output
     * @return {@code true} when at least one entry matches
     */
    static boolean containsGhPagesLeakPattern(String porcelain) {
        if (porcelain == null || porcelain.isBlank()) return false;
        for (String line : porcelain.split("\n")) {
            String path = line.length() > 3 ? line.substring(3).trim() : "";
            if (path.endsWith("/bom.json")
                    || path.endsWith("/built-with.html")
                    || path.endsWith("/dependencies.html")
                    || path.endsWith("/dependency-management.html")
                    || path.endsWith("/distribution-management.html")
                    || path.endsWith("/project-info.html")) {
                return true;
            }
        }
        return false;
    }

    /**
     * Read every {@code pom.xml} reachable via the workspace root or any
     * subproject in the context, extracting each one's {@code <artifactId>}.
     * Used to seed on-disk gh-pages leak detection — the leak directory
     * names always equal one of these.
     *
     * <p>Best-effort: unreadable POMs are silently skipped. Returns a
     * de-duplicated list in encounter order (workspace first, then
     * subprojects in their declared order).
     *
     * @param ctx the preflight context
     * @return distinct artifactIds discovered across the workspace
     */
    static List<String> collectCascadeArtifactIds(PreflightContext ctx) {
        File root = ctx.workspaceRoot();
        var seen = new java.util.LinkedHashSet<String>();
        addArtifactIdIfPresent(new File(root, "pom.xml"), seen);
        for (String name : ctx.subprojects()) {
            addArtifactIdIfPresent(new File(new File(root, name), "pom.xml"),
                    seen);
        }
        return new ArrayList<>(seen);
    }

    private static void addArtifactIdIfPresent(File pom,
                                               java.util.Set<String> accum) {
        if (!pom.isFile()) return;
        try {
            String content = Files.readString(pom.toPath(),
                    StandardCharsets.UTF_8);
            String artifactId = extractTopLevelArtifactId(content);
            if (artifactId != null && !artifactId.isBlank()) {
                accum.add(artifactId);
            }
        } catch (IOException ignore) {
            // best-effort
        }
    }

    /**
     * Extract the top-level {@code <artifactId>} from POM XML — the
     * project's own artifactId, not a {@code <parent>} or
     * {@code <dependency>} reference. Uses a tag-after-tag scan so it
     * doesn't drag in an XML parser dependency.
     *
     * @param pomContent POM XML as a string
     * @return the top-level artifactId, or {@code null} if not found
     */
    static String extractTopLevelArtifactId(String pomContent) {
        if (pomContent == null) return null;
        // Skip any leading <parent>...</parent> block so we don't return
        // the parent's artifactId.
        int searchFrom = 0;
        int parentOpen = pomContent.indexOf("<parent>");
        if (parentOpen >= 0) {
            int parentClose = pomContent.indexOf("</parent>", parentOpen);
            if (parentClose > parentOpen) {
                searchFrom = parentClose + "</parent>".length();
            }
        }
        int open = pomContent.indexOf("<artifactId>", searchFrom);
        if (open < 0) return null;
        int valueStart = open + "<artifactId>".length();
        int close = pomContent.indexOf("</artifactId>", valueStart);
        if (close < 0) return null;
        return pomContent.substring(valueStart, close).trim();
    }

    /**
     * Scan a single POM for a {@code <distributionManagement><site>}
     * block whose {@code <url>} starts with {@code scpexe://}. Used
     * by {@link #NO_SCPEXE_SITE_URLS}.
     *
     * <p>Quietly tolerates unreadable POMs — preflight is best-
     * effort. The check is substring-based on the
     * {@code <distributionManagement>...</distributionManagement>}
     * block so it doesn't false-positive on a scpexe wagon
     * reference outside the site URL (e.g., in a
     * {@code <repository>} block, though that's also gone).
     *
     * @param pom         pom.xml to scan (may not exist)
     * @param displayName label for the violation line
     *                    ({@link #WORKSPACE_ROOT_NAME} or
     *                    subproject name)
     * @param accum       accumulator for formatted violations
     */
    static void scanPomForScpexeSiteUrl(File pom, String displayName,
                                         List<String> accum) {
        if (!pom.isFile()) return;
        String content;
        try {
            content = Files.readString(pom.toPath(),
                    StandardCharsets.UTF_8);
        } catch (IOException e) {
            return; // best-effort
        }
        if (containsScpexeSiteUrl(content)) {
            accum.add(displayName + " (pom.xml has scpexe:// "
                    + "<site><url>)");
        }
    }

    /**
     * Pure-string test for an {@code scpexe://} URL inside a POM's
     * {@code <distributionManagement>} block. Extracted so it's
     * testable without filesystem fixtures.
     *
     * @param pomContent POM XML as a string
     * @return {@code true} when a scpexe:// site URL is present
     */
    static boolean containsScpexeSiteUrl(String pomContent) {
        if (pomContent == null) return false;
        int dmOpen = pomContent.indexOf("<distributionManagement>");
        if (dmOpen < 0) return false;
        int dmClose = pomContent.indexOf("</distributionManagement>",
                dmOpen);
        if (dmClose < dmOpen) return false;
        String block = pomContent.substring(dmOpen, dmClose);
        int siteOpen = block.indexOf("<site>");
        if (siteOpen < 0) return false;
        int siteClose = block.indexOf("</site>", siteOpen);
        if (siteClose < siteOpen) return false;
        String siteBlock = block.substring(siteOpen, siteClose);
        return siteBlock.contains("scpexe://");
    }

    /**
     * Within {@code projectDir}, look for {@code <id>/<id>/index.html}
     * for each {@code id} in {@code artifactIds}. Each hit is added to
     * {@code accum} as the directory containing the {@code index.html}
     * (i.e., {@code projectDir/<id>/<id>}).
     *
     * <p>{@code index.html} is the signal we trust — the maven-site
     * render always produces it, and we want to avoid flagging legit
     * subdirectories that happen to share a name. The doubled-name
     * pattern eliminates almost all collisions on its own; requiring
     * {@code index.html} closes the rest.
     *
     * @param projectDir  directory to search
     * @param artifactIds candidate names to probe
     * @param accum       accumulator for hit paths
     */
    static void detectGhPagesLeakDirs(File projectDir,
                                       List<String> artifactIds,
                                       List<Path> accum) {
        if (projectDir == null || !projectDir.isDirectory()) return;
        Path base = projectDir.toPath();
        for (String id : artifactIds) {
            Path probe = base.resolve(id).resolve(id);
            if (Files.isRegularFile(probe.resolve("index.html"))) {
                accum.add(probe);
            }
        }
    }

    /**
     * If {@code dir/.mvn/jvm.config} exists and contains any line whose
     * first non-empty character is {@code #}, append one entry per
     * offending line to {@code accum}. Format:
     * {@code <displayName>/.mvn/jvm.config:<lineNo>: <text>}.
     *
     * <p>Empty lines and whitespace-only lines are ignored. Quietly
     * tolerates {@link IOException} — preflight is best-effort and
     * shouldn't fail the gate over a transient read error.
     *
     * @param dir         the directory whose {@code .mvn/jvm.config} to scan
     * @param displayName name shown in the violation list
     *                    ({@link #WORKSPACE_ROOT_NAME} for the workspace
     *                    root, otherwise the subproject name)
     * @param accum       accumulator for formatted violation strings
     */
    static void collectJvmConfigViolations(File dir, String displayName,
                                           List<String> accum) {
        Path config = dir.toPath().resolve(".mvn").resolve("jvm.config");
        if (!Files.isRegularFile(config)) return;
        try {
            List<String> lines = Files.readAllLines(config, StandardCharsets.UTF_8);
            for (int i = 0; i < lines.size(); i++) {
                String line = lines.get(i);
                String trimmed = line.stripLeading();
                if (trimmed.startsWith("#")) {
                    accum.add(displayName + "/.mvn/jvm.config:"
                            + (i + 1) + ": " + line);
                }
            }
        } catch (IOException e) {
            // Best-effort — preflight does not fail on read errors.
        }
    }

    /**
     * IKE-foundation property names that
     * {@link #NO_FOUNDATION_PROPERTY_SHADOWING} flags as illegal local
     * overrides.
     */
    static final List<String> FOUNDATION_PROPERTY_NAMES = List.of(
            "ike-tooling.version",
            "ike-docs.version",
            "ike-platform.version");

    /**
     * Return {@code true} when the POM content contains either a
     * {@code <distributionManagement>} block or a {@code <parent>}
     * block. Used by
     * {@link #SUBPROJECT_HAS_DISTRIBUTION_MANAGEMENT} — site:stage
     * needs at least one of these to resolve the deploy target.
     *
     * @param pomContent the POM XML as a string
     * @return {@code true} when at least one block is present
     */
    static boolean hasDistributionManagementOrParent(String pomContent) {
        if (pomContent == null) return false;
        return DISTRIBUTION_MGMT_OR_PARENT.matcher(pomContent).find();
    }

    /**
     * Return {@code true} when the POM has a {@code <properties>} block
     * containing an element whose tag name equals {@code propertyName}.
     * Tolerates whitespace inside the tag value (including blank).
     *
     * @param pomContent   the POM XML as a string
     * @param propertyName the property tag name to scan for
     * @return {@code true} when the property is declared at the
     *         project's top-level {@code <properties>}
     */
    static boolean shadowsProperty(String pomContent, String propertyName) {
        if (pomContent == null || propertyName == null) return false;
        // Scope to <properties>...</properties> at top level — a
        // <properties> nested inside a plugin <configuration> isn't
        // a shadowing site.
        var matcher = TOP_LEVEL_PROPERTIES_BLOCK.matcher(pomContent);
        while (matcher.find()) {
            String block = matcher.group(1);
            if (java.util.regex.Pattern
                    .compile("<" + java.util.regex.Pattern.quote(propertyName)
                            + "\\b")
                    .matcher(block).find()) {
                return true;
            }
        }
        return false;
    }

    private static final java.util.regex.Pattern DISTRIBUTION_MGMT_OR_PARENT =
            java.util.regex.Pattern.compile(
                    "(?s)<distributionManagement\\b|<parent\\b");

    /**
     * Extract the parent {@code groupId:artifactId} from a POM string,
     * or {@code null} when no {@code <parent>} block is present.
     *
     * @param pomContent the POM XML
     * @return {@code "groupId:artifactId"} or {@code null}
     */
    static String extractParentGa(String pomContent) {
        if (pomContent == null) return null;
        var parentMatcher = java.util.regex.Pattern.compile(
                "(?s)<parent\\b[^>]*>(.*?)</parent>").matcher(pomContent);
        if (!parentMatcher.find()) return null;
        String block = parentMatcher.group(1);
        var gMatch = java.util.regex.Pattern.compile(
                "<groupId>\\s*([^<]+?)\\s*</groupId>").matcher(block);
        var aMatch = java.util.regex.Pattern.compile(
                "<artifactId>\\s*([^<]+?)\\s*</artifactId>").matcher(block);
        if (!gMatch.find() || !aMatch.find()) return null;
        return gMatch.group(1).trim() + ":" + aMatch.group(1).trim();
    }

    /**
     * Extract the parent {@code <version>} from a POM string, or
     * {@code null} when no {@code <parent>} block is present or it
     * lacks an explicit version.
     *
     * @param pomContent the POM XML
     * @return the version string or {@code null}
     */
    static String extractParentVersion(String pomContent) {
        if (pomContent == null) return null;
        var parentMatcher = java.util.regex.Pattern.compile(
                "(?s)<parent\\b[^>]*>(.*?)</parent>").matcher(pomContent);
        if (!parentMatcher.find()) return null;
        String block = parentMatcher.group(1);
        var vMatch = java.util.regex.Pattern.compile(
                "<version>\\s*([^<]+?)\\s*</version>").matcher(block);
        if (!vMatch.find()) return null;
        return vMatch.group(1).trim();
    }

    /**
     * Match the project's top-level {@code <properties>} block.
     * Anchored to follow either {@code </artifactId>},
     * {@code </version>}, or {@code </name>} so it doesn't match
     * nested {@code <properties>} inside plugin {@code <configuration>}
     * blocks. Best-effort — handles the common shape of IKE POMs.
     */
    private static final java.util.regex.Pattern TOP_LEVEL_PROPERTIES_BLOCK =
            java.util.regex.Pattern.compile(
                    "(?s)<properties>\\s*(.*?)\\s*</properties>");
}