DependsOnCycleGate.java
package network.ike.plugin.ws;
import network.ike.workspace.Dependency;
import network.ike.workspace.Manifest;
import network.ike.workspace.Subproject;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Acyclicity gate for the repo-level {@code depends-on} graph that
* {@code workspace.yaml} derivation is about to write
* (IKE-Network/ike-issues#962).
*
* <p>Contracting the module graph to repo granularity can manufacture
* a cycle that no module edge actually forms — a reactor leaf bundling
* a sibling repo's artifact while that repo builds against other
* modules of the same reactor (the {@code komet ⇄ komet-claude-plugin}
* shape). A manifest carrying such a cycle is rejected by every
* graph-consuming {@code ws:} goal, blocking the whole workspace, so
* derivation must detect the cycle <em>before</em> writing and report
* the contributing module-level edges instead.
*
* <p>Only build-ordering edges participate: {@code relationship:
* bundle} entries (package-time bundling of a repository-resolved
* artifact, IKE-Network/ike-issues#963) order nothing and are skipped.
*
* <p>Used by {@link YamlDepsSync} (re-derivation post-step, which
* warns and leaves the manifest untouched) and {@code ws:add}
* (which restores the pre-add files and fails the goal).
*/
final class DependsOnCycleGate {
private DependsOnCycleGate() {}
/**
* Build the repo-level build-ordering graph of a manifest: every
* subproject mapped to its {@code depends-on} targets, minus
* {@code bundle} edges and targets not present in the manifest.
*
* @param manifest the parsed workspace manifest
* @return name → ordering-edge targets, in manifest order
*/
static Map<String, Set<String>> orderingGraph(Manifest manifest) {
Map<String, Set<String>> graph = new LinkedHashMap<>();
Set<String> known = manifest.subprojects().keySet();
for (Map.Entry<String, Subproject> entry
: manifest.subprojects().entrySet()) {
graph.put(entry.getKey(),
orderingTargets(entry.getValue(), known));
}
return graph;
}
/**
* The subproject's build-ordering edge targets: every
* {@code depends-on} entry except {@code relationship: bundle},
* restricted to targets that exist in the manifest.
*
* @param sub the subproject whose edges to collect
* @param known all manifest subproject names
* @return ordering-edge targets in declaration order
*/
static Set<String> orderingTargets(Subproject sub, Set<String> known) {
Set<String> targets = new LinkedHashSet<>();
if (sub.dependsOn() == null) return targets;
for (Dependency dep : sub.dependsOn()) {
if ("bundle".equalsIgnoreCase(dep.relationship())) continue;
if (known.contains(dep.subproject())) {
targets.add(dep.subproject());
}
}
return targets;
}
/**
* Find a cycle in a repo-level graph. Iterative DFS with
* three-color marking, mirroring {@code WorkspaceGraph.detectCycle}.
*
* @param edges name → edge targets
* @return the cycle as a closed path (first element repeated last),
* or an empty list when the graph is acyclic
*/
static List<String> findCycle(Map<String, Set<String>> edges) {
Set<String> white = new LinkedHashSet<>(edges.keySet());
Set<String> gray = new LinkedHashSet<>();
Map<String, String> parent = new LinkedHashMap<>();
for (String start : edges.keySet()) {
if (!white.contains(start)) continue;
Deque<String> stack = new ArrayDeque<>();
stack.push(start);
while (!stack.isEmpty()) {
String current = stack.peek();
if (white.remove(current)) {
gray.add(current);
for (String dep : edges.getOrDefault(current, Set.of())) {
if (gray.contains(dep)) {
return reconstructCycle(parent, current, dep);
}
if (white.contains(dep)) {
parent.put(dep, current);
stack.push(dep);
}
}
} else {
stack.pop();
gray.remove(current);
}
}
}
return List.of();
}
private static List<String> reconstructCycle(Map<String, String> parent,
String from, String to) {
List<String> cycle = new ArrayList<>();
cycle.add(to);
String current = from;
while (!current.equals(to)) {
cycle.add(current);
current = parent.getOrDefault(current, to);
}
cycle.add(to);
Collections.reverse(cycle);
return cycle;
}
/**
* Render the abort diagnostic: the repo-level cycle plus, for each
* edge with source attribution, the module POM references that
* create it — file:line and coordinate.
*
* @param workspaceRoot workspace root, for relative display paths
* @param cycle closed cycle path from {@link #findCycle}
* @param edgeSources from-subproject → (to-subproject → refs);
* edges without attribution are reported as
* declared in workspace.yaml
* @return the multi-line diagnostic
*/
static String diagnostic(
Path workspaceRoot,
List<String> cycle,
Map<String, Map<String, List<WsAddMojo.PomRef>>> edgeSources) {
StringBuilder d = new StringBuilder();
d.append("depends-on derivation would write a repo-level ")
.append("dependency cycle: ")
.append(String.join(" -> ", cycle))
.append('\n');
for (int i = 0; i < cycle.size() - 1; i++) {
String from = cycle.get(i);
String to = cycle.get(i + 1);
d.append(" ").append(from).append(" -> ").append(to).append('\n');
List<WsAddMojo.PomRef> refs = edgeSources
.getOrDefault(from, Map.of()).get(to);
if (refs == null || refs.isEmpty()) {
d.append(" declared in workspace.yaml ")
.append("(not re-derived in this run)\n");
continue;
}
int shown = 0;
for (WsAddMojo.PomRef ref : refs) {
if (shown == 3) {
d.append(" ... and ").append(refs.size() - 3)
.append(" more\n");
break;
}
d.append(" from ")
.append(location(workspaceRoot, ref))
.append(" (").append(ref.coordinate()).append(")\n");
shown++;
}
}
d.append("workspace.yaml left unchanged — a manifest with this cycle ")
.append("would block every ws: goal. If an edge bundles a ")
.append("repository-resolved artifact rather than ordering ")
.append("the build, declare it 'relationship: bundle' ")
.append("(IKE-Network/ike-issues#963); ")
.append("see IKE-Network/ike-issues#962.");
return d.toString();
}
/**
* Workspace-relative {@code path:line} for a POM reference, the
* line being the first occurrence of the referenced artifactId in
* the file (best effort — omitted when not found).
*/
private static String location(Path workspaceRoot, WsAddMojo.PomRef ref) {
Path file = ref.pomFile();
String display;
try {
display = workspaceRoot.relativize(file).toString();
} catch (IllegalArgumentException e) {
display = file.toString();
}
int colon = ref.coordinate().indexOf(':');
String artifactId = colon < 0
? ref.coordinate()
: ref.coordinate().substring(colon + 1);
int line = lineOf(file, "<artifactId>" + artifactId + "</artifactId>");
return line > 0 ? display + ":" + line : display;
}
/** 1-based line of the first occurrence of {@code needle}, or 0. */
private static int lineOf(Path file, String needle) {
try {
List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
for (int i = 0; i < lines.size(); i++) {
if (lines.get(i).contains(needle)) return i + 1;
}
} catch (IOException e) {
// best effort — fall through
}
return 0;
}
}