implements PR-19.2 dependency graph analiser extraction

This commit is contained in:
bQUARKz 2026-03-26 19:04:22 +00:00
parent 82d2bfafb8
commit b190227b53
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
4 changed files with 306 additions and 116 deletions

View File

@ -6,12 +6,9 @@ import p.studio.compiler.models.DependencyContext;
import p.studio.compiler.models.ProjectInfoId;
import p.studio.compiler.source.identifiers.ProjectId;
import p.studio.compiler.workspaces.DependencyPhase;
import p.studio.utilities.structures.ReadOnlyList;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import p.studio.utilities.structures.DependencyGraphAnaliser;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import static java.util.stream.Collectors.joining;
@ -21,62 +18,11 @@ public final class StackPhase implements DependencyPhase {
*/
@Override
public BuildingIssueSink run(final DependencyContext ctx) {
final int n = ctx.projectTable.size();
final int[] indeg = new int[n];
for (int from = 0; from < n; from++) {
for (final ProjectId to : ctx.dependenciesByProject.get(from)) {
indeg[to.getIndex()]++;
}
}
final var analysis = new DependencyGraphAnaliser<ProjectId>(java.util.Comparator.comparingInt(ProjectId::getIndex))
.analyse(buildDependencyGraph(ctx));
final Deque<Integer> q = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
if (indeg[i] == 0) q.add(i);
}
final List<ProjectId> travesalOrder = new ArrayList<>(n);
// Performs topological sort using Kahn's algorithm
while (!q.isEmpty()) {
final int u = q.removeFirst();
//travesalOrder.add(ctx.projectDescriptors.get(u).getProjectId());
travesalOrder.add(new ProjectId(u));
for (final ProjectId v : ctx.dependenciesByProject.get(u)) {
if (--indeg[v.getId()] == 0) {
q.addLast(v.getId());
}
}
}
if (travesalOrder.size() != n) {
boolean[] stuck = new boolean[n];
for (int i = 0; i < n; i++) stuck[i] = indeg[i] > 0;
var tarjan = new TarjanScc(n, ctx.dependenciesByProject);
final List<List<Integer>> sccs = tarjan.run(stuck);
final List<List<ProjectId>> cycles = new ArrayList<>();
for (var scc : sccs) {
if (scc.size() > 1) {
final var cycle = scc.stream().map(ProjectId::new).toList();
cycles.add(cycle);
} else {
// size==1: cycle only if self-loop exists
final var u = scc.getFirst();
final var projectId = new ProjectId(u);
boolean selfLoop = false;
for (final var pu : ctx.dependenciesByProject.get(u)) {
if (pu.getIndex() == u) {
selfLoop = true;
break;
}
}
if (selfLoop) {
cycles.add(List.of(projectId));
}
}
}
final var msg = "[DEPS]: cycle(s) detected:\n" + cycles.stream()
if (!analysis.acyclic()) {
final var msg = "[DEPS]: cycle(s) detected:\n" + analysis.cycleComponents().stream()
.map(projectIds -> " * " + projectIds.stream()
.map(pId -> ctx.projectInfoTable.get(new ProjectInfoId(pId.getIndex())).manifest.name())
.collect(joining(" -> "))
@ -87,65 +33,16 @@ public final class StackPhase implements DependencyPhase {
.report(builder -> builder.error(true).message(msg));
}
ctx.stack = new BuildStack(ReadOnlyList.wrap(travesalOrder));
ctx.stack = new BuildStack(analysis.traversalOrder());
return BuildingIssueSink.empty();
}
static final class TarjanScc {
private final int n;
private final List<List<ProjectId>> adj;
private int time = 0;
private final int[] disc;
private final int[] low;
private final boolean[] inStack;
private final Deque<Integer> stack = new ArrayDeque<>();
TarjanScc(int n, List<List<ProjectId>> adj) {
this.n = n;
this.adj = adj;
this.disc = new int[n];
this.low = new int[n];
this.inStack = new boolean[n];
java.util.Arrays.fill(disc, -1);
}
List<List<Integer>> run(boolean[] allowed) {
List<List<Integer>> sccs = new ArrayList<>();
for (int i = 0; i < n; i++) {
if (allowed[i] && disc[i] == -1) dfs(i, allowed, sccs);
}
return sccs;
}
private void dfs(int u, boolean[] allowed, List<List<Integer>> out) {
disc[u] = low[u] = time++;
stack.push(u);
inStack[u] = true;
for (ProjectId pid : adj.get(u)) {
int v = pid.getIndex();
if (!allowed[v]) continue;
if (disc[v] == -1) {
dfs(v, allowed, out);
low[u] = Math.min(low[u], low[v]);
} else if (inStack[v]) {
low[u] = Math.min(low[u], disc[v]);
}
}
if (low[u] == disc[u]) {
List<Integer> scc = new ArrayList<>();
while (true) {
int w = stack.pop();
inStack[w] = false;
scc.add(w);
if (w == u) break;
}
out.add(scc);
}
private LinkedHashMap<ProjectId, LinkedHashSet<ProjectId>> buildDependencyGraph(final DependencyContext ctx) {
final var graph = new LinkedHashMap<ProjectId, LinkedHashSet<ProjectId>>();
for (int index = 0; index < ctx.projectTable.size(); index++) {
graph.put(new ProjectId(index), new LinkedHashSet<>(ctx.dependenciesByProject.get(index)));
}
return graph;
}
}

View File

@ -0,0 +1,72 @@
package p.studio.compiler.workspaces.phases;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.studio.compiler.messages.DependencyConfig;
import p.studio.compiler.models.DependencyContext;
import p.studio.compiler.models.ProjectDescriptor;
import p.studio.compiler.models.ProjectInfo;
import p.studio.compiler.models.PrometeuManifest;
import p.studio.compiler.source.identifiers.ProjectId;
import p.studio.utilities.structures.ReadOnlyList;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class StackPhaseTest {
@TempDir
Path tempDir;
@Test
void shouldBuildDeterministicTopologicalOrder() {
final var ctx = DependencyContext.basedOn(new DependencyConfig(false, tempDir));
registerProject(ctx, "app");
registerProject(ctx, "core");
registerProject(ctx, "util");
ctx.dependenciesByProject.add(List.of(new ProjectId(1), new ProjectId(2)));
ctx.dependenciesByProject.add(List.of(new ProjectId(2)));
ctx.dependenciesByProject.add(List.of());
final var issues = new StackPhase().run(ctx);
assertTrue(!issues.hasErrors());
assertEquals(ReadOnlyList.wrap(List.of(new ProjectId(2), new ProjectId(1), new ProjectId(0))), ctx.stack.topologicalOrder);
}
@Test
void shouldReportCycles() {
final var ctx = DependencyContext.basedOn(new DependencyConfig(false, tempDir));
registerProject(ctx, "a");
registerProject(ctx, "b");
registerProject(ctx, "c");
ctx.dependenciesByProject.add(List.of(new ProjectId(1)));
ctx.dependenciesByProject.add(List.of(new ProjectId(2)));
ctx.dependenciesByProject.add(List.of(new ProjectId(0)));
final var issues = new StackPhase().run(ctx);
assertTrue(issues.hasErrors());
assertTrue(issues.asCollection().stream().anyMatch(issue -> issue.getMessage().contains("a -> b -> c")));
}
private void registerProject(final DependencyContext ctx, final String name) {
final var root = tempDir.resolve(name);
ctx.projectTable.register(ProjectDescriptor.builder()
.rootPath(root)
.name(name)
.version("1.0.0")
.sourceRoots(ReadOnlyList.wrap(List.of()))
.build());
ctx.projectInfoTable.register(ProjectInfo.builder()
.rootDirectory(root)
.manifestPath(root.resolve("prometeu.json"))
.manifest(new PrometeuManifest(name, "1.0.0", "pbs", 1, ReadOnlyList.wrap(List.of())))
.build());
}
}

View File

@ -0,0 +1,154 @@
package p.studio.utilities.structures;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
import java.util.Set;
public final class DependencyGraphAnaliser<N> {
private final Comparator<? super N> nodeOrder;
public DependencyGraphAnaliser(final Comparator<? super N> nodeOrder) {
this.nodeOrder = nodeOrder;
}
public Analysis<N> analyse(final Map<N, ? extends Collection<N>> dependenciesByNode) {
final var canonicalGraph = canonicalize(dependenciesByNode);
final var indegree = new HashMap<N, Integer>();
final var reverse = new HashMap<N, LinkedHashSet<N>>();
for (final var node : canonicalGraph.keySet()) {
indegree.put(node, 0);
reverse.put(node, new LinkedHashSet<>());
}
for (final var entry : canonicalGraph.entrySet()) {
for (final var dependency : entry.getValue()) {
indegree.put(entry.getKey(), indegree.get(entry.getKey()) + 1);
reverse.get(dependency).add(entry.getKey());
}
}
final var ready = new PriorityQueue<N>(nodeOrder);
for (final var entry : indegree.entrySet()) {
if (entry.getValue() == 0) {
ready.add(entry.getKey());
}
}
final var traversalOrder = new ArrayList<N>(canonicalGraph.size());
while (!ready.isEmpty()) {
final var current = ready.remove();
traversalOrder.add(current);
for (final var dependent : reverse.getOrDefault(current, new LinkedHashSet<>())) {
final var next = indegree.get(dependent) - 1;
indegree.put(dependent, next);
if (next == 0) {
ready.add(dependent);
}
}
}
final var stronglyConnectedComponents = tarjan(canonicalGraph);
final var cycleComponents = new ArrayList<ReadOnlyList<N>>();
for (final var scc : stronglyConnectedComponents) {
if (scc.size() > 1) {
cycleComponents.add(scc);
continue;
}
final var node = scc.getFirst();
if (canonicalGraph.getOrDefault(node, new LinkedHashSet<>()).contains(node)) {
cycleComponents.add(scc);
}
}
return new Analysis<>(
ReadOnlyList.wrap(traversalOrder),
ReadOnlyList.wrap(stronglyConnectedComponents),
ReadOnlyList.wrap(cycleComponents),
traversalOrder.size() == canonicalGraph.size());
}
private LinkedHashMap<N, LinkedHashSet<N>> canonicalize(final Map<N, ? extends Collection<N>> dependenciesByNode) {
final var graph = new LinkedHashMap<N, LinkedHashSet<N>>();
for (final var entry : dependenciesByNode.entrySet()) {
graph.computeIfAbsent(entry.getKey(), ignored -> new LinkedHashSet<>());
for (final var dependency : entry.getValue()) {
graph.computeIfAbsent(dependency, ignored -> new LinkedHashSet<>());
graph.get(entry.getKey()).add(dependency);
}
}
return graph;
}
private List<ReadOnlyList<N>> tarjan(final LinkedHashMap<N, LinkedHashSet<N>> graph) {
final var indexByNode = new HashMap<N, Integer>();
final var lowLinkByNode = new HashMap<N, Integer>();
final var inStack = new HashSet<N>();
final var stack = new ArrayDeque<N>();
final var components = new ArrayList<ReadOnlyList<N>>();
final var indexRef = new int[]{0};
for (final var node : graph.keySet()) {
if (!indexByNode.containsKey(node)) {
strongConnect(node, graph, indexByNode, lowLinkByNode, inStack, stack, components, indexRef);
}
}
return components;
}
private void strongConnect(
final N node,
final Map<N, LinkedHashSet<N>> graph,
final Map<N, Integer> indexByNode,
final Map<N, Integer> lowLinkByNode,
final Set<N> inStack,
final Deque<N> stack,
final List<ReadOnlyList<N>> components,
final int[] indexRef) {
indexByNode.put(node, indexRef[0]);
lowLinkByNode.put(node, indexRef[0]);
indexRef[0]++;
stack.push(node);
inStack.add(node);
for (final var dependency : graph.getOrDefault(node, new LinkedHashSet<>())) {
if (!indexByNode.containsKey(dependency)) {
strongConnect(dependency, graph, indexByNode, lowLinkByNode, inStack, stack, components, indexRef);
lowLinkByNode.put(node, Math.min(lowLinkByNode.get(node), lowLinkByNode.get(dependency)));
} else if (inStack.contains(dependency)) {
lowLinkByNode.put(node, Math.min(lowLinkByNode.get(node), indexByNode.get(dependency)));
}
}
if (lowLinkByNode.get(node).equals(indexByNode.get(node))) {
final var component = new ArrayList<N>();
while (true) {
final var current = stack.pop();
inStack.remove(current);
component.add(current);
if (current.equals(node)) {
break;
}
}
component.sort(nodeOrder);
components.add(ReadOnlyList.wrap(component));
}
}
public record Analysis<N>(
ReadOnlyList<N> traversalOrder,
ReadOnlyList<ReadOnlyList<N>> stronglyConnectedComponents,
ReadOnlyList<ReadOnlyList<N>> cycleComponents,
boolean acyclic) {
}
}

View File

@ -0,0 +1,67 @@
package p.studio.utilities.structures;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DependencyGraphAnaliserTest {
@Test
void shouldTopologicallyOrderSimpleDag() {
final var graph = new LinkedHashMap<String, Set<String>>();
graph.put("app", Set.of("core", "util"));
graph.put("core", Set.of("util"));
graph.put("util", Set.of());
final var analysis = new DependencyGraphAnaliser<>(String::compareTo).analyse(graph);
assertTrue(analysis.acyclic());
assertEquals(ReadOnlyList.wrap(List.of("util", "core", "app")), analysis.traversalOrder());
assertTrue(analysis.cycleComponents().isEmpty());
}
@Test
void shouldDetectCycle() {
final var graph = new LinkedHashMap<String, Set<String>>();
graph.put("a", Set.of("b"));
graph.put("b", Set.of("c"));
graph.put("c", Set.of("a"));
final var analysis = new DependencyGraphAnaliser<>(String::compareTo).analyse(graph);
assertFalse(analysis.acyclic());
assertEquals(1, analysis.cycleComponents().size());
assertEquals(ReadOnlyList.wrap(List.of("a", "b", "c")), analysis.cycleComponents().getFirst());
}
@Test
void shouldProduceDeterministicOrderForDisconnectedGraph() {
final var graph = new LinkedHashMap<String, Set<String>>();
graph.put("zeta", Set.of());
graph.put("alpha", Set.of());
graph.put("beta", Set.of("alpha"));
final var analysis = new DependencyGraphAnaliser<>(String::compareTo).analyse(graph);
assertTrue(analysis.acyclic());
assertEquals(ReadOnlyList.wrap(List.of("alpha", "beta", "zeta")), analysis.traversalOrder());
}
@Test
void shouldTreatSelfLoopAsCycle() {
final var graph = new LinkedHashMap<String, Set<String>>();
graph.put("self", Set.of("self"));
final var analysis = new DependencyGraphAnaliser<>(String::compareTo).analyse(graph);
assertFalse(analysis.acyclic());
assertEquals(1, analysis.cycleComponents().size());
assertEquals(ReadOnlyList.wrap(List.of("self")), analysis.cycleComponents().getFirst());
}
}