From 486f49f8079e5e21e2c64c3d78dc04fcc7036105 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 4 May 2026 04:34:10 -0500 Subject: [PATCH] feat(graph): implement round-based topological sort Replace sortNodeIDs() single-queue Kahn's algorithm with round-based collection. Each round gathers all zero-in-degree nodes, sorts them alphabetically within the round, then advances. This ensures nodes at the same dependency depth are grouped together rather than being interleaved with deeper nodes via global alphabetical ordering. --- pkg/devcontainer/graph/graph.go | 40 +++++++++++++++++++++------- pkg/devcontainer/graph/graph_test.go | 33 +++++++++++++++++++++++ 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/pkg/devcontainer/graph/graph.go b/pkg/devcontainer/graph/graph.go index a48ccd5c1..f19a2618e 100644 --- a/pkg/devcontainer/graph/graph.go +++ b/pkg/devcontainer/graph/graph.go @@ -284,21 +284,22 @@ func (g *Graph[T]) HasCircularDependency() bool { func (g *Graph[T]) sortNodeIDs() ([]string, error) { workingInDegree := copyInDegreeMap(g.inDegree) - zeroInDegreeQueue := initializeQueue(workingInDegree) sortedResult := make([]string, 0, len(g.nodes)) - for len(zeroInDegreeQueue) > 0 { - currentNode := dequeue(&zeroInDegreeQueue) - sortedResult = append(sortedResult, currentNode) - processNeighbors(g.edges, currentNode, workingInDegree, &zeroInDegreeQueue) + for { + round := collectZeroInDegree(workingInDegree) + if len(round) == 0 { + break + } + sort.Strings(round) + sortedResult = append(sortedResult, round...) + advanceRound(round, g.edges, workingInDegree) } if len(sortedResult) != len(g.nodes) { - remainingNodes := []string{} - for nodeID, degree := range workingInDegree { - if degree > 0 { - remainingNodes = append(remainingNodes, nodeID) - } + remainingNodes := make([]string, 0, len(workingInDegree)) + for nodeID := range workingInDegree { + remainingNodes = append(remainingNodes, nodeID) } sort.Strings(remainingNodes) return nil, fmt.Errorf("circular dependency detected among nodes: %v", remainingNodes) @@ -307,6 +308,25 @@ func (g *Graph[T]) sortNodeIDs() ([]string, error) { return sortedResult, nil } +func collectZeroInDegree(inDegree map[string]int) []string { + round := make([]string, 0) + for nodeID, degree := range inDegree { + if degree == 0 { + round = append(round, nodeID) + } + } + return round +} + +func advanceRound(round []string, edges map[string][]string, inDegree map[string]int) { + for _, nodeID := range round { + delete(inDegree, nodeID) + for _, neighbor := range edges[nodeID] { + inDegree[neighbor]-- + } + } +} + func (g *Graph[T]) collectChildren(id string, nodesToRemove *[]string) { stack := []string{id} visited := make(map[string]bool) diff --git a/pkg/devcontainer/graph/graph_test.go b/pkg/devcontainer/graph/graph_test.go index 16b91a72e..55c0f108a 100644 --- a/pkg/devcontainer/graph/graph_test.go +++ b/pkg/devcontainer/graph/graph_test.go @@ -523,6 +523,39 @@ func (suite *GraphTestSuite) TestLargeGraph() { suite.Len(result, nodeCount) } +func (suite *GraphTestSuite) TestSortNodeIDsRoundBased() { + // A→B edge means B depends on A. + // Round 1: A and C both have in-degree 0 → sorted alpha → [A, C] + // Round 2: B (now in-degree 0 after A processed) → [B] + // Final: [A, C, B] + suite.Require().NoError(suite.graph.AddNode("A", "dataA")) + suite.Require().NoError(suite.graph.AddNode("B", "dataB")) + suite.Require().NoError(suite.graph.AddNode("C", "dataC")) + suite.Require().NoError(suite.graph.AddEdge("A", "B")) + + result, err := suite.graph.SortNodeIDs() + suite.NoError(err) + suite.Equal([]string{"A", "C", "B"}, result) +} + +func (suite *GraphTestSuite) TestSortNodeIDsRoundBasedMultiLevel() { + // A→B→D, C→D (independent chains with shared sink) + // Round 1: A, C (in-degree 0) → [A, C] + // Round 2: B (depends on A only) → [B] + // Round 3: D (depends on B and C, but C was processed in round 1) → [D] + suite.Require().NoError(suite.graph.AddNode("A", "dataA")) + suite.Require().NoError(suite.graph.AddNode("B", "dataB")) + suite.Require().NoError(suite.graph.AddNode("C", "dataC")) + suite.Require().NoError(suite.graph.AddNode("D", "dataD")) + suite.Require().NoError(suite.graph.AddEdge("A", "B")) + suite.Require().NoError(suite.graph.AddEdge("B", "D")) + suite.Require().NoError(suite.graph.AddEdge("C", "D")) + + result, err := suite.graph.SortNodeIDs() + suite.NoError(err) + suite.Equal([]string{"A", "C", "B", "D"}, result) +} + func (suite *GraphTestSuite) TestTopologicalSortAdvanced() { testCases := []struct { name string