Implement cycle detection and prevention improvements

- Add diagnostic warnings when cycles detected after dep add (bd-309)
- Add semantic validation for parent-child dependency direction (bd-308)
- Document cycle handling behavior in code, README, and DESIGN (bd-310)

Changes:
- cmd/bd/dep.go: Add DetectCycles() call and warning after dep add
- internal/storage/sqlite/dependencies.go: Add parent-child direction validation and comprehensive cycle prevention comments
- internal/storage/sqlite/dependencies_test.go: Add TestParentChildValidation
- README.md: Add dependency types and cycle prevention section with examples
- DESIGN.md: Add detailed cycle prevention design rationale and trade-offs
This commit is contained in:
Steve Yegge
2025-10-16 13:17:32 -07:00
parent 6753024eb0
commit 1e32041fe6
6 changed files with 531 additions and 385 deletions

View File

@@ -46,6 +46,21 @@ func (s *SQLiteStorage) AddDependency(ctx context.Context, dep *types.Dependency
return fmt.Errorf("issue cannot depend on itself")
}
// Validate parent-child dependency direction
// In parent-child relationships: child depends on parent (child is part of parent)
// Parent should NOT depend on child (semantically backwards)
// Consistent with dependency semantics: IssueID depends on DependsOnID
if dep.Type == types.DepParentChild {
// issueExists is the dependent (the one that depends on something)
// dependsOnExists is what it depends on
// Correct: Task (child) depends on Epic (parent) - child belongs to parent
// Incorrect: Epic (parent) depends on Task (child) - backwards
if issueExists.IssueType == types.TypeEpic && dependsOnExists.IssueType != types.TypeEpic {
return fmt.Errorf("invalid parent-child dependency: parent (%s) cannot depend on child (%s). Use: bd dep add %s %s --type parent-child",
dep.IssueID, dep.DependsOnID, dep.DependsOnID, dep.IssueID)
}
}
dep.CreatedAt = time.Now()
dep.CreatedBy = actor
@@ -55,12 +70,27 @@ func (s *SQLiteStorage) AddDependency(ctx context.Context, dep *types.Dependency
}
defer tx.Rollback()
// Check if this would create a cycle across ALL dependency types
// We check before inserting to avoid unnecessary write on failure
// We traverse all dependency types to detect cross-type cycles
// (e.g., A blocks B, B parent-child A would create a cycle)
// We need to check if we can reach IssueID from DependsOnID
// If yes, adding "IssueID depends on DependsOnID" would create a cycle
// Cycle Detection and Prevention
//
// We prevent cycles across ALL dependency types (blocks, related, parent-child, discovered-from)
// to maintain a directed acyclic graph (DAG). This is critical for:
//
// 1. Ready Work Calculation: Cycles can hide issues from the ready list by making them
// appear blocked when they're actually part of a circular dependency.
//
// 2. Dependency Traversal: Operations like dep tree and blocking propagation rely on
// DAG structure. Cycles would require special handling and could cause confusion.
//
// 3. Semantic Clarity: Circular dependencies are conceptually problematic - if A depends
// on B and B depends on A (directly or through other issues), which should be done first?
//
// Implementation: We use a recursive CTE to traverse from DependsOnID to see if we can
// reach IssueID. If yes, adding "IssueID depends on DependsOnID" would complete a cycle.
// We check ALL dependency types because cross-type cycles (e.g., A blocks B, B parent-child A)
// are just as problematic as single-type cycles.
//
// The traversal is depth-limited to maxDependencyDepth (100) to prevent infinite loops
// and excessive query cost. We check before inserting to avoid unnecessary write on failure.
var cycleExists bool
err = tx.QueryRowContext(ctx, `
WITH RECURSIVE paths AS (

View File

@@ -59,6 +59,58 @@ func TestAddDependencyDiscoveredFrom(t *testing.T) {
testAddDependencyWithType(t, types.DepDiscoveredFrom, "Parent task", "Bug found during work")
}
func TestParentChildValidation(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create an epic (parent) and a task (child)
epic := &types.Issue{Title: "Epic Feature", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic}
task := &types.Issue{Title: "Subtask", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, epic, "test-user")
store.CreateIssue(ctx, task, "test-user")
// Test 1: Valid direction - Task depends on Epic (child belongs to parent)
err := store.AddDependency(ctx, &types.Dependency{
IssueID: task.ID,
DependsOnID: epic.ID,
Type: types.DepParentChild,
}, "test-user")
if err != nil {
t.Fatalf("Valid parent-child dependency failed: %v", err)
}
// Verify it was added
deps, err := store.GetDependencies(ctx, task.ID)
if err != nil {
t.Fatalf("GetDependencies failed: %v", err)
}
if len(deps) != 1 {
t.Fatalf("Expected 1 dependency, got %d", len(deps))
}
// Remove the dependency for next test
err = store.RemoveDependency(ctx, task.ID, epic.ID, "test-user")
if err != nil {
t.Fatalf("RemoveDependency failed: %v", err)
}
// Test 2: Invalid direction - Epic depends on Task (parent depends on child - backwards!)
err = store.AddDependency(ctx, &types.Dependency{
IssueID: epic.ID,
DependsOnID: task.ID,
Type: types.DepParentChild,
}, "test-user")
if err == nil {
t.Fatal("Expected error when parent depends on child, but got none")
}
if !strings.Contains(err.Error(), "child") || !strings.Contains(err.Error(), "parent") {
t.Errorf("Expected error message to mention child/parent relationship, got: %v", err)
}
}
func TestRemoveDependency(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()