diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index ba7b2d5f..ce949701 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -56,6 +56,7 @@ This command checks: - Daemon health (version mismatches, stale processes) - Database-JSONL sync status - File permissions + - Circular dependencies Examples: bd doctor # Check current directory @@ -165,6 +166,13 @@ func runDiagnostics(path string) doctorResult { result.OverallOK = false } + // Check 10: Dependency cycles + cycleCheck := checkDependencyCycles(path) + result.Checks = append(result.Checks, cycleCheck) + if cycleCheck.Status == statusError || cycleCheck.Status == statusWarning { + result.OverallOK = false + } + return result } @@ -855,6 +863,100 @@ func checkPermissions(path string) doctorCheck { } } +func checkDependencyCycles(path string) doctorCheck { + beadsDir := filepath.Join(path, ".beads") + dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) + + // If no database, skip this check + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + return doctorCheck{ + Name: "Dependency Cycles", + Status: statusOK, + Message: "N/A (no database)", + } + } + + // Open database to check for cycles + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return doctorCheck{ + Name: "Dependency Cycles", + Status: statusWarning, + Message: "Unable to open database", + Detail: err.Error(), + } + } + defer db.Close() + + // Query for cycles using simplified SQL + query := ` + WITH RECURSIVE paths AS ( + SELECT + issue_id, + depends_on_id, + issue_id as start_id, + issue_id || '→' || depends_on_id as path, + 0 as depth + FROM dependencies + + UNION ALL + + SELECT + d.issue_id, + d.depends_on_id, + p.start_id, + p.path || '→' || d.depends_on_id, + p.depth + 1 + FROM dependencies d + JOIN paths p ON d.issue_id = p.depends_on_id + WHERE p.depth < 100 + AND p.path NOT LIKE '%' || d.depends_on_id || '→%' + ) + SELECT DISTINCT start_id + FROM paths + WHERE depends_on_id = start_id` + + rows, err := db.Query(query) + if err != nil { + return doctorCheck{ + Name: "Dependency Cycles", + Status: statusWarning, + Message: "Unable to check for cycles", + Detail: err.Error(), + } + } + defer rows.Close() + + cycleCount := 0 + var firstCycle string + for rows.Next() { + var startID string + if err := rows.Scan(&startID); err != nil { + continue + } + cycleCount++ + if cycleCount == 1 { + firstCycle = startID + } + } + + if cycleCount == 0 { + return doctorCheck{ + Name: "Dependency Cycles", + Status: statusOK, + Message: "No circular dependencies detected", + } + } + + return doctorCheck{ + Name: "Dependency Cycles", + Status: statusError, + Message: fmt.Sprintf("Found %d circular dependency cycle(s)", cycleCount), + Detail: fmt.Sprintf("First cycle involves: %s", firstCycle), + Fix: "Run 'bd dep cycles' to see full cycle paths, then 'bd dep remove' to break cycles", + } +} + func init() { doctorCmd.Flags().Bool("json", false, "Output JSON format") rootCmd.AddCommand(doctorCmd)