Files
beads/internal/storage/sqlite/cycle_detection_test.go
Steve Yegge 844e9ffc02 fix(deps): exclude relates-to from cycle detection (fixes #661)
relates-to dependencies are intentionally bidirectional ("see also" links)
and should not be reported as cycles by bd dep cycles. This matches the
existing behavior in AddDependency which already skips cycle prevention
for relates-to.

Changes:
- Filter relates-to edges in DetectCycles SQL query
- Add tests for relates-to exclusion from cycle detection
- Update existing test to reflect correct behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 13:33:15 -08:00

646 lines
19 KiB
Go

package sqlite
import (
"context"
"strconv"
"testing"
"github.com/steveyegge/beads/internal/types"
)
// TestDetectCyclesSimple tests simple 2-node cycles
func TestDetectCyclesSimple(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create two issues
issue1 := &types.Issue{Title: "Issue 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issue2 := &types.Issue{Title: "Issue 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
if err := store.CreateIssue(ctx, issue1, "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
if err := store.CreateIssue(ctx, issue2, "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
// Manually create a cycle by inserting directly into dependencies table
// (bypassing AddDependency's cycle prevention)
_, err := store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issue1.ID, issue2.ID, types.DepBlocks)
if err != nil {
t.Fatalf("Insert dependency failed: %v", err)
}
_, err = store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issue2.ID, issue1.ID, types.DepBlocks)
if err != nil {
t.Fatalf("Insert dependency failed: %v", err)
}
// Detect cycles
cycles, err := store.DetectCycles(ctx)
if err != nil {
t.Fatalf("DetectCycles failed: %v", err)
}
if len(cycles) == 0 {
t.Fatal("Expected to detect a cycle, but found none")
}
// Verify the cycle contains both issues
cycle := cycles[0]
if len(cycle) != 2 {
t.Logf("Cycle issues: %v", cycle)
for i, iss := range cycle {
t.Logf(" [%d] ID=%s Title=%s", i, iss.ID, iss.Title)
}
t.Errorf("Expected cycle of length 2, got %d", len(cycle))
}
// Verify both issues are in the cycle
foundIDs := make(map[string]bool)
for _, issue := range cycle {
foundIDs[issue.ID] = true
}
if !foundIDs[issue1.ID] || !foundIDs[issue2.ID] {
t.Errorf("Cycle missing expected issues. Got: %v", foundIDs)
}
}
// TestDetectCyclesComplex tests a more complex multi-node cycle
func TestDetectCyclesComplex(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create a 4-node cycle: A → B → C → D → A
issues := make([]*types.Issue, 4)
for i := 0; i < 4; i++ {
issues[i] = &types.Issue{
Title: "Issue " + string(rune('A'+i)),
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
}
// Create cycle: 0→1→2→3→0
for i := 0; i < 4; i++ {
nextIdx := (i + 1) % 4
_, err := store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issues[i].ID, issues[nextIdx].ID, types.DepBlocks)
if err != nil {
t.Fatalf("Insert dependency failed: %v", err)
}
}
// Detect cycles
cycles, err := store.DetectCycles(ctx)
if err != nil {
t.Fatalf("DetectCycles failed: %v", err)
}
if len(cycles) == 0 {
t.Fatal("Expected to detect a cycle, but found none")
}
// Verify the cycle contains all 4 issues
cycle := cycles[0]
if len(cycle) != 4 {
t.Errorf("Expected cycle of length 4, got %d", len(cycle))
}
// Verify all issues are in the cycle
foundIDs := make(map[string]bool)
for _, issue := range cycle {
foundIDs[issue.ID] = true
}
for _, issue := range issues {
if !foundIDs[issue.ID] {
t.Errorf("Cycle missing issue %s", issue.ID)
}
}
}
// TestDetectCyclesSelfLoop tests detection of self-loops (A → A)
func TestDetectCyclesSelfLoop(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
issue := &types.Issue{Title: "Self Loop", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
// Create self-loop
_, err := store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issue.ID, issue.ID, types.DepBlocks)
if err != nil {
t.Fatalf("Insert dependency failed: %v", err)
}
// Detect cycles
cycles, err := store.DetectCycles(ctx)
if err != nil {
t.Fatalf("DetectCycles failed: %v", err)
}
if len(cycles) == 0 {
t.Fatal("Expected to detect a self-loop cycle, but found none")
}
// Verify the cycle contains the issue
cycle := cycles[0]
if len(cycle) != 1 {
t.Errorf("Expected self-loop cycle of length 1, got %d", len(cycle))
}
if cycle[0].ID != issue.ID {
t.Errorf("Expected cycle to contain issue %s, got %s", issue.ID, cycle[0].ID)
}
}
// TestDetectCyclesMultipleIndependent tests detection of multiple independent cycles
func TestDetectCyclesMultipleIndependent(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create two independent cycles:
// Cycle 1: A → B → A
// Cycle 2: C → D → C
cycle1 := make([]*types.Issue, 2)
cycle2 := make([]*types.Issue, 2)
for i := 0; i < 2; i++ {
cycle1[i] = &types.Issue{
Title: "Cycle1-" + string(rune('A'+i)),
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
cycle2[i] = &types.Issue{
Title: "Cycle2-" + string(rune('A'+i)),
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, cycle1[i], "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
if err := store.CreateIssue(ctx, cycle2[i], "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
}
// Create first cycle: 0→1→0
_, err := store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, cycle1[0].ID, cycle1[1].ID, types.DepBlocks)
if err != nil {
t.Fatalf("Insert dependency failed: %v", err)
}
_, err = store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, cycle1[1].ID, cycle1[0].ID, types.DepBlocks)
if err != nil {
t.Fatalf("Insert dependency failed: %v", err)
}
// Create second cycle: 0→1→0
_, err = store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, cycle2[0].ID, cycle2[1].ID, types.DepBlocks)
if err != nil {
t.Fatalf("Insert dependency failed: %v", err)
}
_, err = store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, cycle2[1].ID, cycle2[0].ID, types.DepBlocks)
if err != nil {
t.Fatalf("Insert dependency failed: %v", err)
}
// Detect cycles
cycles, err := store.DetectCycles(ctx)
if err != nil {
t.Fatalf("DetectCycles failed: %v", err)
}
// The SQL may detect the same cycle from different entry points,
// so we might get more than 2 cycles reported. Verify we have at least 2.
if len(cycles) < 2 {
t.Errorf("Expected to detect at least 2 independent cycles, got %d", len(cycles))
}
// Verify we found cycles involving all 4 issues
foundIssues := make(map[string]bool)
for _, cycle := range cycles {
for _, issue := range cycle {
foundIssues[issue.ID] = true
}
}
allCycleIssues := append(cycle1, cycle2...)
for _, issue := range allCycleIssues {
if !foundIssues[issue.ID] {
t.Errorf("Cycle detection missing issue %s", issue.ID)
}
}
}
// TestDetectCyclesAcyclicGraph tests that no cycles are detected in an acyclic graph
func TestDetectCyclesAcyclicGraph(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create a DAG: A → B → C → D (no cycles)
issues := make([]*types.Issue, 4)
for i := 0; i < 4; i++ {
issues[i] = &types.Issue{
Title: "Issue " + string(rune('A'+i)),
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
}
// Create chain: 0→1→2→3 (no cycle)
for i := 0; i < 3; i++ {
_, err := store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issues[i].ID, issues[i+1].ID, types.DepBlocks)
if err != nil {
t.Fatalf("Insert dependency failed: %v", err)
}
}
// Detect cycles
cycles, err := store.DetectCycles(ctx)
if err != nil {
t.Fatalf("DetectCycles failed: %v", err)
}
if len(cycles) != 0 {
t.Errorf("Expected no cycles in acyclic graph, but found %d", len(cycles))
}
}
// TestDetectCyclesEmptyGraph tests cycle detection on empty graph
func TestDetectCyclesEmptyGraph(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Detect cycles on empty database
cycles, err := store.DetectCycles(ctx)
if err != nil {
t.Fatalf("DetectCycles failed: %v", err)
}
if len(cycles) != 0 {
t.Errorf("Expected no cycles in empty graph, but found %d", len(cycles))
}
}
// TestDetectCyclesSingleNode tests cycle detection with a single node (no dependencies)
func TestDetectCyclesSingleNode(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create a single issue with no dependencies
issue := &types.Issue{Title: "Lonely Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
// Detect cycles
cycles, err := store.DetectCycles(ctx)
if err != nil {
t.Fatalf("DetectCycles failed: %v", err)
}
if len(cycles) != 0 {
t.Errorf("Expected no cycles for single node with no dependencies, but found %d", len(cycles))
}
}
// TestDetectCyclesDiamond tests cycle detection in a diamond pattern (no cycle)
func TestDetectCyclesDiamond(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create a diamond pattern: A → B → D, A → C → D (no cycle)
issues := make([]*types.Issue, 4)
names := []string{"A", "B", "C", "D"}
for i := 0; i < 4; i++ {
issues[i] = &types.Issue{
Title: "Issue " + names[i],
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
}
// Create dependencies: A→B, A→C, B→D, C→D
deps := [][2]int{{0, 1}, {0, 2}, {1, 3}, {2, 3}}
for _, dep := range deps {
_, err := store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issues[dep[0]].ID, issues[dep[1]].ID, types.DepBlocks)
if err != nil {
t.Fatalf("Insert dependency failed: %v", err)
}
}
// Detect cycles
cycles, err := store.DetectCycles(ctx)
if err != nil {
t.Fatalf("DetectCycles failed: %v", err)
}
if len(cycles) != 0 {
t.Errorf("Expected no cycles in diamond pattern, but found %d", len(cycles))
}
}
// TestDetectCyclesLongCycle tests detection of a long cycle (10 nodes)
func TestDetectCyclesLongCycle(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create a 10-node cycle
const cycleLength = 10
issues := make([]*types.Issue, cycleLength)
for i := 0; i < cycleLength; i++ {
issues[i] = &types.Issue{
Title: "Issue " + strconv.Itoa(i),
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
}
// Create cycle: 0→1→2→...→9→0
for i := 0; i < cycleLength; i++ {
nextIdx := (i + 1) % cycleLength
_, err := store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issues[i].ID, issues[nextIdx].ID, types.DepBlocks)
if err != nil {
t.Fatalf("Insert dependency failed: %v", err)
}
}
// Detect cycles
cycles, err := store.DetectCycles(ctx)
if err != nil {
t.Fatalf("DetectCycles failed: %v", err)
}
if len(cycles) == 0 {
t.Fatal("Expected to detect a cycle, but found none")
}
// Verify the cycle contains all 10 issues
cycle := cycles[0]
if len(cycle) != cycleLength {
t.Errorf("Expected cycle of length %d, got %d", cycleLength, len(cycle))
}
}
// TestDetectCyclesMixedTypes tests cycle detection with different dependency types
func TestDetectCyclesMixedTypes(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create a cycle using different dependency types
issues := make([]*types.Issue, 3)
for i := 0; i < 3; i++ {
issues[i] = &types.Issue{
Title: "Issue " + string(rune('A'+i)),
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
}
// Create cycle with mixed types: A -blocks-> B -related-> C -parent-child-> A
depTypes := []types.DependencyType{types.DepBlocks, types.DepRelated, types.DepParentChild}
for i := 0; i < 3; i++ {
nextIdx := (i + 1) % 3
_, err := store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issues[i].ID, issues[nextIdx].ID, depTypes[i])
if err != nil {
t.Fatalf("Insert dependency failed: %v", err)
}
}
// Detect cycles
cycles, err := store.DetectCycles(ctx)
if err != nil {
t.Fatalf("DetectCycles failed: %v", err)
}
if len(cycles) == 0 {
t.Fatal("Expected to detect a cycle with mixed types, but found none")
}
// Verify the cycle contains all 3 issues
cycle := cycles[0]
if len(cycle) != 3 {
t.Errorf("Expected cycle of length 3, got %d", len(cycle))
}
}
// TestDetectCyclesRelatesToNotACycle tests that bidirectional relates-to links are NOT reported as cycles
// This is the fix for GitHub issue #661: relates-to relationships should be excluded from cycle detection
// because they are inherently bidirectional ("see also" links) and don't affect work ordering.
func TestDetectCyclesRelatesToNotACycle(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create two issues
issue1 := &types.Issue{Title: "Todo 1", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
issue2 := &types.Issue{Title: "Todo 2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
if err := store.CreateIssue(ctx, issue1, "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
if err := store.CreateIssue(ctx, issue2, "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
// Create bidirectional relates_to links (simulating what bd relate does)
// issue1 relates_to issue2
_, err := store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issue1.ID, issue2.ID, types.DepRelatesTo)
if err != nil {
t.Fatalf("Insert relates_to dependency failed: %v", err)
}
// issue2 relates_to issue1 (the reverse link)
_, err = store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issue2.ID, issue1.ID, types.DepRelatesTo)
if err != nil {
t.Fatalf("Insert relates_to dependency failed: %v", err)
}
// Detect cycles - should find NONE because relates_to is excluded
cycles, err := store.DetectCycles(ctx)
if err != nil {
t.Fatalf("DetectCycles failed: %v", err)
}
if len(cycles) != 0 {
t.Errorf("Expected no cycles for relates_to bidirectional links, but found %d cycles", len(cycles))
for i, cycle := range cycles {
t.Logf("Cycle %d:", i+1)
for _, issue := range cycle {
t.Logf(" - %s: %s", issue.ID, issue.Title)
}
}
}
}
// TestDetectCyclesRelatesToWithOtherCycle tests that relates-to is excluded but other cycles are still detected
func TestDetectCyclesRelatesToWithOtherCycle(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create three issues
issue1 := &types.Issue{Title: "Issue A", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
issue2 := &types.Issue{Title: "Issue B", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
issue3 := &types.Issue{Title: "Issue C", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
if err := store.CreateIssue(ctx, issue1, "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
if err := store.CreateIssue(ctx, issue2, "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
if err := store.CreateIssue(ctx, issue3, "test-user"); err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
// Create bidirectional relates_to between issue1 and issue2 (should NOT trigger cycle)
_, err := store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issue1.ID, issue2.ID, types.DepRelatesTo)
if err != nil {
t.Fatalf("Insert relates_to dependency failed: %v", err)
}
_, err = store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issue2.ID, issue1.ID, types.DepRelatesTo)
if err != nil {
t.Fatalf("Insert relates_to dependency failed: %v", err)
}
// Create a real cycle with blocks: issue2 -> issue3 -> issue2
_, err = store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issue2.ID, issue3.ID, types.DepBlocks)
if err != nil {
t.Fatalf("Insert blocks dependency failed: %v", err)
}
_, err = store.db.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
`, issue3.ID, issue2.ID, types.DepBlocks)
if err != nil {
t.Fatalf("Insert blocks dependency failed: %v", err)
}
// Detect cycles - should find the blocks cycle but NOT the relates_to "cycle"
cycles, err := store.DetectCycles(ctx)
if err != nil {
t.Fatalf("DetectCycles failed: %v", err)
}
if len(cycles) == 0 {
t.Fatal("Expected to find the blocks cycle, but found none")
}
// Verify the cycle contains issue2 and issue3, but NOT issue1
foundIDs := make(map[string]bool)
for _, cycle := range cycles {
for _, issue := range cycle {
foundIDs[issue.ID] = true
}
}
if !foundIDs[issue2.ID] || !foundIDs[issue3.ID] {
t.Errorf("Expected cycle to contain issue2 and issue3. Found: %v", foundIDs)
}
// Verify issue1 is NOT in the cycle (it's only connected via relates-to)
if foundIDs[issue1.ID] {
t.Errorf("issue1 should NOT be in cycle (only connected via relates-to), but was found")
}
}