WorkspaceVerifier.java
package network.ike.plugin.ws.verify;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.ws.Ansi;
import network.ike.plugin.ws.PomParentSupport;
import network.ike.plugin.ws.vcs.VcsOperations;
import network.ike.plugin.ws.vcs.VcsState;
import network.ike.workspace.BomAnalysis;
import network.ike.workspace.DependencyConvergenceAnalysis;
import network.ike.workspace.DependencyConvergenceAnalysis.Divergence;
import network.ike.workspace.DependencyTreeParser;
import network.ike.workspace.DependencyTreeParser.ResolvedDependency;
import network.ike.workspace.PublishedArtifactSet;
import network.ike.workspace.Subproject;
import network.ike.workspace.WorkspaceGraph;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.MojoException;
import java.io.File;
import java.io.IOException;
import java.io.StringReader;
import java.net.InetAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
/**
* Workspace-wide verification — the read-only logic formerly in
* the retired {@code ws:verify} goal (IKE-Network/ike-issues#393).
* Now invoked by {@code ws:scaffold-draft} as part of its drift
* report.
*
* <p>Each {@code verifyXxx} method translates one check from the
* original mojo. The {@code verifyParentAlignment()} check was
* intentionally dropped during the extraction: parent drift is now
* detected by {@code ParentVersionReconciler.detect()} (one of the
* workspace-level reconcilers run by {@code ws:scaffold-draft})
* and would otherwise duplicate that warning.
*/
public final class WorkspaceVerifier {
private final Log log;
private final WorkspaceGraph graph;
private final File root;
private final Path manifestPath;
private final boolean checkConvergence;
private final boolean workspaceMode;
private final List<String[]> rows = new ArrayList<>();
/**
* Construct a verifier bound to a single workspace.
*
* @param log Maven logger for streaming check output
* @param graph the workspace graph (already loaded by caller)
* @param root workspace root directory
* @param manifestPath path to {@code workspace.yaml}
* @param checkConvergence run the slow transitive-convergence check
* @param workspaceMode {@code true} when running inside a
* workspace; {@code false} for a bare repo
*/
public WorkspaceVerifier(Log log, WorkspaceGraph graph, File root,
Path manifestPath, boolean checkConvergence,
boolean workspaceMode) {
this.log = log;
this.graph = graph;
this.root = root;
this.manifestPath = manifestPath;
this.checkConvergence = checkConvergence;
this.workspaceMode = workspaceMode;
}
/**
* Run every verification check; returns the row data the caller
* uses to render its markdown report. Also logs check progress
* and outcomes to the {@link Log} passed in the constructor.
*
* @return per-check {@code {label, status}} rows
* @throws MojoException if a verification step fails irrecoverably
*/
public List<String[]> runAllChecks() throws MojoException {
log.info("");
log.info(header("Verification"));
log.info("══════════════════════════════════════════════════════════════");
if (workspaceMode) {
verifyWorkspaceManifest();
// Parent alignment intentionally omitted — superseded by
// ParentVersionReconciler.detect() (#393).
verifyParentCoherence();
verifyBomCascade();
if (checkConvergence) {
verifyDependencyConvergence();
}
verifyWorkspaceVcs();
} else {
verifyBareVcs();
}
verifyEnvironment();
log.info("");
return rows;
}
// ── Workspace manifest verification ───────────────────────────
private void verifyWorkspaceManifest() throws MojoException {
List<String> errors = graph.verify();
int subprojectCount = graph.manifest().subprojects().size();
log.info(" Components: " + subprojectCount);
log.info("");
if (errors.isEmpty()) {
log.info(Ansi.green(" Manifest: consistent ✓"));
rows.add(new String[]{"Manifest", "consistent ✓"});
} else {
log.error(" Manifest: " + errors.size() + " error(s)");
for (String error : errors) {
log.error(" ✗ " + error);
}
rows.add(new String[]{"Manifest",
errors.size() + " error(s)"});
}
}
// ── Parent coherence — same GA as workspace's parent ─────────
//
// ike-issues#324: when a subproject's <parent> declares the SAME
// groupId+artifactId as the workspace aggregator's own <parent>
// (typically network.ike.platform:ike-parent for IKE-Network
// workspaces), two policy rules apply:
//
// 1. Cycle prevention. The subproject must include an empty
// <relativePath/> in its <parent> block. Without it,
// Maven 4's model builder fails with "The parents form a
// cycle" — the subproject's parent lookup tries the
// filesystem first, finds an ike-parent at a different
// version, can't reconcile, and bails. Established
// precedent in komet workspaces; documented in MAVEN.md
// and IKE-WORKSPACE.md.
//
// 2. Version coherence. The subproject's <parent><version>
// must equal the workspace aggregator's. Version drift
// means subprojects inherit different pluginManagement +
// dependencyManagement matrices — a silent loss of
// consistency across the workspace.
private void verifyParentCoherence() throws MojoException {
Path workspacePom = root.toPath().resolve("pom.xml");
if (!Files.exists(workspacePom)) {
// Bare-VCS or unusual layout — nothing to enforce.
return;
}
PomParentSupport.ParentInfo workspaceParent;
try {
workspaceParent = PomParentSupport.readParent(workspacePom);
} catch (IOException e) {
log.debug(" Could not read workspace parent: " + e.getMessage());
return;
}
if (workspaceParent == null
|| workspaceParent.groupId() == null
|| workspaceParent.artifactId() == null) {
// Workspace doesn't inherit a parent — no coherence to
// enforce on subprojects.
return;
}
int cycleRisk = 0;
int versionDrift = 0;
int coherent = 0;
log.info("");
for (Map.Entry<String, Subproject> entry :
graph.manifest().subprojects().entrySet()) {
String name = entry.getKey();
Path pomFile = root.toPath().resolve(name).resolve("pom.xml");
if (!Files.exists(pomFile)) continue;
PomParentSupport.ParentInfo subParent;
try {
subParent = PomParentSupport.readParent(pomFile);
} catch (IOException e) {
log.debug(" Could not read " + name + " parent: "
+ e.getMessage());
continue;
}
if (subParent == null) continue;
// Decision matrix gate: subproject's parent GA must match
// the workspace aggregator's parent GA. Otherwise this
// subproject inherits a different parent and is out of
// scope for these rules.
if (!Objects.equals(workspaceParent.groupId(),
subParent.groupId())
|| !Objects.equals(workspaceParent.artifactId(),
subParent.artifactId())) {
continue;
}
String coordinates = subParent.groupId() + ":"
+ subParent.artifactId();
// Rule 2: version coherence
if (!Objects.equals(workspaceParent.version(),
subParent.version())) {
log.warn(" WARN: " + name + " parent "
+ coordinates + ":" + subParent.version()
+ " != workspace " + coordinates + ":"
+ workspaceParent.version()
+ " (#324 coherence violation)");
versionDrift++;
continue;
}
// Rule 1: cycle prevention — empty <relativePath/> required
boolean hasEmptyRelativePath;
try {
hasEmptyRelativePath =
PomParentSupport.hasEmptyRelativePath(pomFile);
} catch (IOException e) {
log.debug(" Could not check relativePath for "
+ name + ": " + e.getMessage());
continue;
}
if (!hasEmptyRelativePath) {
log.warn(" WARN: " + name + " parent "
+ coordinates + ":" + subParent.version()
+ " matches workspace parent but is missing "
+ "empty <relativePath/> (#324 cycle prevention; "
+ "add <relativePath/> inside the <parent> block)");
cycleRisk++;
continue;
}
coherent++;
}
int problems = cycleRisk + versionDrift;
int total = problems + coherent;
if (total == 0) {
log.info(" Parent coherence: no subprojects share the "
+ "workspace's parent GA");
rows.add(new String[]{"Parent coherence", "n/a"});
} else if (problems == 0) {
log.info(Ansi.green(" Parent coherence: " + coherent
+ " subproject(s) coherent ✓"));
rows.add(new String[]{"Parent coherence",
coherent + " coherent ✓"});
} else {
String summary = versionDrift + " version drift, "
+ cycleRisk + " missing <relativePath/>";
log.warn(" Parent coherence: " + summary);
rows.add(new String[]{"Parent coherence", summary});
}
}
// ── BOM cascade verification ──────────────────────────────────
private void verifyBomCascade() throws MojoException {
// Build published artifact sets for all subprojects
Map<String, Set<PublishedArtifactSet.Artifact>> workspaceArtifacts =
new LinkedHashMap<>();
for (String name : graph.manifest().subprojects().keySet()) {
Path subDir = root.toPath().resolve(name);
if (Files.exists(subDir.resolve("pom.xml"))) {
try {
workspaceArtifacts.put(name,
PublishedArtifactSet.scan(subDir));
} catch (IOException e) {
log.debug("Could not scan " + name + ": " + e.getMessage());
}
}
}
try {
var issues = BomAnalysis.analyzeCascadeIssues(
root.toPath(), graph.manifest(), workspaceArtifacts);
if (issues.isEmpty()) {
log.info("");
log.info(" BOM cascade: all dependency edges can cascade ✓");
rows.add(new String[]{"BOM cascade", "all edges cascade ✓"});
} else {
log.info("");
log.warn(" BOM cascade: " + issues.size() + " gap(s) detected");
rows.add(new String[]{"BOM cascade",
issues.size() + " gap(s)"});
for (var issue : issues) {
log.warn(" " + issue.subprojectName() + " → "
+ issue.dependsOn()
+ ": no version-property or workspace BOM import");
if (!issue.externalBomPins().isEmpty()) {
for (var bom : issue.externalBomPins()) {
log.warn(" external BOM: "
+ bom.groupId() + ":" + bom.artifactId()
+ ":" + bom.version()
+ " (may pin workspace artifact versions)");
}
}
}
}
} catch (IOException e) {
log.warn(" BOM cascade check failed: " + e.getMessage());
}
}
// ── Dependency convergence check ───────────────────────────────
private void verifyDependencyConvergence() throws MojoException {
log.info("");
log.info(" Dependency convergence (this may take a while)...");
log.info("");
File mvnExecutable = ReleaseSupport.resolveMavenWrapper(root, log);
// Collect dependency trees per subproject in topological order
List<String> order = graph.topologicalSort();
Map<String, List<ResolvedDependency>> componentTrees =
new LinkedHashMap<>();
for (String name : order) {
File subDir = new File(root, name);
File pomFile = new File(subDir, "pom.xml");
if (!pomFile.exists()) continue;
log.info(" Resolving " + name + "...");
try {
String treeOutput = ReleaseSupport.execCapture(subDir,
mvnExecutable.getAbsolutePath(),
"dependency:tree", "-DoutputType=text",
"-B", "-q");
List<ResolvedDependency> deps =
DependencyTreeParser.parse(treeOutput);
if (!deps.isEmpty()) {
componentTrees.put(name, deps);
}
} catch (MojoException e) {
log.warn(Ansi.yellow(" ⚠ ") + name + ": dependency:tree failed — "
+ e.getMessage());
}
}
if (componentTrees.size() < 2) {
log.info(" Fewer than 2 components resolved — skipping analysis");
return;
}
// Analyze
List<Divergence> divergences =
DependencyConvergenceAnalysis.analyze(componentTrees);
if (divergences.isEmpty()) {
log.info("");
log.info(" Convergence: all shared dependencies converge across "
+ componentTrees.size() + " components ✓");
rows.add(new String[]{"Convergence",
"all converge ✓ (" + componentTrees.size() + " components)"});
} else {
log.info("");
rows.add(new String[]{"Convergence",
divergences.size() + " divergence(s)"});
log.info(" Convergence: " + divergences.size()
+ " artifact(s) diverge across "
+ componentTrees.size() + " components");
log.info("");
for (Divergence d : divergences) {
log.info(" " + d.coordinate());
for (var vEntry : d.versionToSubprojects().entrySet()) {
log.info(" " + vEntry.getKey() + " ← "
+ String.join(", ", vEntry.getValue()));
}
}
}
}
// ── Subproject git state (workspace mode) ─────────────────────
private void verifyWorkspaceVcs() throws MojoException {
log.info("");
// Workspace repo itself
if (VcsState.isIkeManaged(root.toPath())) {
log.info(" Workspace");
reportVcsState(root, " ");
}
// Each subproject
for (var entry : graph.manifest().subprojects().entrySet()) {
String name = entry.getKey();
File dir = new File(root, name);
if (!new File(dir, ".git").exists()) {
continue;
}
log.info(" " + name);
if (!VcsState.isIkeManaged(dir.toPath())) {
log.info(" Git state: freshly added (no workspace operations yet)");
continue;
}
reportVcsState(dir, " ");
}
}
// ── Subproject git state (bare mode) ──────────────────────────
private void verifyBareVcs() throws MojoException {
File dir = new File(System.getProperty("user.dir"));
String dirName = dir.getName();
log.info(" Machine: " + hostname());
if (!VcsState.isIkeManaged(dir.toPath())) {
log.info(" Git state: freshly added (no workspace operations yet)");
return;
}
log.info("");
log.info(" " + dirName);
reportVcsState(dir, " ");
}
// ── Shared VCS state reporting ───────────────────────────────
private void reportVcsState(File dir, String indent)
throws MojoException {
String localBranch = gitBranch(dir);
String localSha = gitShortSha(dir);
log.info(indent + "Branch: " + localBranch);
log.info(indent + "Local HEAD: " + localSha);
Optional<VcsState> stateOpt = VcsState.readFrom(dir.toPath());
if (stateOpt.isEmpty()) {
log.info(indent + "State file: absent (first commit, or Syncthing not delivered)");
log.info(indent + "Status: no state file ─");
return;
}
VcsState state = stateOpt.get();
log.info(indent + "State file: " + state.action().label()
+ " by " + state.machine() + " at " + state.timestamp());
log.info(indent + "State SHA: " + state.sha());
log.info(indent + "State branch: " + state.branch());
// In sync?
boolean shaMatch = state.sha().equals(localSha);
boolean branchMatch = state.branch().equals(localBranch);
if (shaMatch && branchMatch) {
log.info(indent + "Status: in sync ✓");
return;
}
// Not in sync — diagnose based on action
if (!branchMatch) {
diagnoseBranchMismatch(dir, indent, state, localBranch);
} else {
diagnoseShaMismatch(dir, indent, state, localSha);
}
}
private void diagnoseBranchMismatch(File dir, String indent,
VcsState state, String localBranch) {
switch (state.action()) {
case FEATURE_START -> {
log.warn(indent + "Status: feature branch '"
+ state.branch() + "' started on " + state.machine()
+ " at " + state.timestamp());
log.warn(indent + " You are on '"
+ localBranch + "'.");
log.warn(indent + "Action: run 'mvnw ws:switch -Dbranch="
+ state.branch() + "' to follow the feature branch");
}
case FEATURE_FINISH -> {
log.warn(indent + "Status: feature finished on "
+ state.machine() + " at " + state.timestamp()
+ ", merged to '" + state.branch() + "'");
log.warn(indent + " You are on '"
+ localBranch + "'.");
log.warn(indent + "Action: run 'mvnw ws:switch -Dbranch="
+ state.branch() + "' to return to '"
+ state.branch() + "'");
}
case SWITCH -> {
log.warn(indent + "Status: switched to '"
+ state.branch() + "' on " + state.machine()
+ " at " + state.timestamp());
log.warn(indent + " You are on '"
+ localBranch + "'.");
log.warn(indent + "Action: run 'mvnw ws:switch -Dbranch="
+ state.branch() + "' or 'mvnw ws:reconcile-branches-publish"
+ " -Dfrom=manifest'");
}
case COMMIT, PUSH, RELEASE, CHECKPOINT -> {
log.warn(indent + "Status: branch mismatch — local '"
+ localBranch + "', state file '" + state.branch() + "'");
log.warn(indent + "Action: run 'mvnw ws:switch -Dbranch="
+ state.branch() + "' to reconcile");
}
}
}
private void diagnoseShaMismatch(File dir, String indent,
VcsState state, String localSha) {
// Check if the state SHA exists on the remote
Optional<String> remoteSha;
try {
remoteSha = VcsOperations.remoteSha(dir, "origin", state.branch());
} catch (MojoException e) {
remoteSha = Optional.empty();
}
boolean shaOnRemote = remoteSha.isPresent();
switch (state.action()) {
case COMMIT -> {
if (shaOnRemote) {
log.warn(indent + "Status: commit on "
+ state.machine() + " at " + state.timestamp());
log.warn(indent + "Action: run 'mvnw ws:pull'");
} else {
log.warn(indent + "Status: commit on "
+ state.machine() + " at " + state.timestamp()
+ ", but push did not complete");
log.warn(indent + "Action: push from "
+ state.machine() + " first, then 'mvnw ws:pull' here");
log.warn(indent + " Or: IKE_VCS_OVERRIDE=1 to proceed independently");
}
}
case PUSH -> {
log.warn(indent + "Status: push from "
+ state.machine() + " at " + state.timestamp());
log.warn(indent + " Local HEAD behind remote.");
log.warn(indent + "Action: run 'mvnw ws:pull'");
}
case RELEASE -> {
log.warn(indent + "Status: release performed on "
+ state.machine() + " at " + state.timestamp());
log.warn(indent + "Action: run 'mvnw ws:pull'");
}
case CHECKPOINT -> {
log.warn(indent + "Status: checkpoint created on "
+ state.machine() + " at " + state.timestamp());
log.warn(indent + "Action: run 'mvnw ws:pull'");
}
case SWITCH -> {
log.warn(indent + "Status: switched on "
+ state.machine() + " at " + state.timestamp());
log.warn(indent + "Action: run 'mvnw ws:reconcile-branches-publish"
+ " -Dfrom=manifest'");
}
case FEATURE_START, FEATURE_FINISH -> {
log.warn(indent + "Status: behind ("
+ state.action().label() + " on " + state.machine() + ")");
log.warn(indent + "Action: run 'mvnw ws:pull'");
}
}
}
// ── Environment checks ──────────────────────────────────────
private void verifyEnvironment() {
File dir = new File(System.getProperty("user.dir"));
log.info("");
// Standards
File standards = new File(dir, ".claude/standards");
if (standards.isDirectory()) {
log.info(" Standards: .claude/standards/ present ✓");
} else {
log.info(" Standards: .claude/standards/ absent");
}
// CLAUDE.md
File claudeMd = new File(dir, "CLAUDE.md");
if (claudeMd.exists()) {
log.info(" CLAUDE.md: present ✓");
} else {
log.info(" CLAUDE.md: absent");
}
// Syncthing
checkSyncthingHealth();
}
private void checkSyncthingHealth() {
int port = 8384;
// Check for custom port in .ike/config
File dir = new File(System.getProperty("user.dir"));
Path config = dir.toPath().resolve(".ike/config");
if (Files.exists(config)) {
try {
Properties props = new Properties();
props.load(new StringReader(
Files.readString(config, StandardCharsets.UTF_8)));
String portStr = props.getProperty("syncthing.port");
if (portStr != null) {
port = Integer.parseInt(portStr.trim());
}
} catch (Exception e) {
log.debug("Could not read .ike/config: " + e.getMessage());
}
}
try {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/rest/noauth/health"))
.timeout(Duration.ofSeconds(2))
.GET()
.build();
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
log.info(" Syncthing: connected (port " + port + ") ✓");
} else {
log.info(" Syncthing: responded with status "
+ response.statusCode());
}
} catch (Exception e) {
log.info(" Syncthing: not running (port " + port + ")");
}
}
// ── Inlined helpers from AbstractWorkspaceMojo ────────────────
/**
* Format a goal header line using the workspace name.
* Inlined equivalent of {@code AbstractWorkspaceMojo#header}.
*/
private String header(String goalName) {
return workspaceName() + " — " + goalName;
}
/**
* Read the workspace name from the root POM's artifactId.
* Falls back to {@code "Workspace"} if the POM cannot be read.
* Inlined equivalent of {@code AbstractWorkspaceMojo#workspaceName}.
*/
private String workspaceName() {
try {
File rootPom = new File(root, "pom.xml");
if (rootPom.exists()) {
return ReleaseSupport.readPomArtifactId(rootPom);
}
} catch (Exception e) {
// Fall through
}
return "Workspace";
}
/**
* Inlined equivalent of {@code AbstractWorkspaceMojo#gitBranch}.
*/
private static String gitBranch(File dir) {
try {
return ReleaseSupport.execCapture(dir,
"git", "rev-parse", "--abbrev-ref", "HEAD");
} catch (Exception e) {
return "unknown";
}
}
/**
* Inlined equivalent of {@code AbstractWorkspaceMojo#gitShortSha}.
*/
private static String gitShortSha(File dir) {
try {
return ReleaseSupport.execCapture(dir,
"git", "rev-parse", "--short", "HEAD");
} catch (Exception e) {
return "???????";
}
}
private static String hostname() {
String host = System.getenv("HOSTNAME");
if (host == null || host.isEmpty()) {
try {
host = InetAddress.getLocalHost().getHostName();
} catch (Exception e) {
host = "unknown";
}
}
int dot = host.indexOf('.');
return dot > 0 ? host.substring(0, dot) : host;
}
}