philiprehberger-dependency_graph 0.1.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7adfe2ea19f84682a7098e4b2721e0da82302a2820b795910d7eed48b8ab2ba
4
- data.tar.gz: d7440b81ffe5f4a9a7b84ef2c70bd39b6a6f31115213df1dcef51ebce7c05669
3
+ metadata.gz: 2fc52e7f3c4c62be66dc6d19901b9d82c953fa8c5ea32f22b3cdada283c02fd5
4
+ data.tar.gz: d334350bfb017e0172ec0145718904875c94fd1568e6114f3a113b8f1a124ae1
5
5
  SHA512:
6
- metadata.gz: 6372378bf705c2d620a0971b47a5fd52c3664664ec11388f983b318cc3f5548557fe5395a744d9b440deb85cb1144425009ec63211cfc525ea93c9e38ce39f78
7
- data.tar.gz: c038fa5661134768db05a5833ae52d912cb19e3bf3d53ffc998a665375d40f6dca1bb6ccb9fa0cbd8f08a00c61c0ce15163f0c627548c99cec9b8e4dd889900a
6
+ metadata.gz: bf8205242047d62b202affb156583a13d89cdeb3db6d59fb7c4eeec1d9fb0b06ea23ffe5da925f37d97dafe0fb1b1cec628deba7bd24b33ac975e049add9758a
7
+ data.tar.gz: 5116b0ec4782836b7b74b0f7135b842af9774b2108a9820212f0a73466840769f623688b5384084ca78597fd5299aa4ebb9b9a1dfdf3d4be5c7b5cd5b2c1a11d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-04-09
11
+
12
+ ### Added
13
+ - `Graph#reverse` — return a new graph with all edges flipped (useful for dependent analysis)
14
+ - `Graph#all_dependents_of(item)` — transitive closure of items depending on a node
15
+ - `Graph#independent?(a, b)` — check whether two nodes are mutually unreachable
16
+
17
+ ## [0.2.0] - 2026-04-03
18
+
19
+ ### Added
20
+ - `Graph#dependencies_of(item)` — returns direct dependencies of a node
21
+ - `Graph#all_dependencies_of(item)` — returns transitive closure of all dependencies
22
+ - `Graph#dependents_of(item)` — reverse lookup of items that depend on a node
23
+ - `Graph#path(from, to)` — BFS shortest dependency path between two nodes
24
+ - `Graph#subgraph(*items)` — extract a new graph containing only specified nodes and their edges
25
+ - `Graph#roots` — nodes with no dependencies
26
+ - `Graph#leaves` — nodes with no dependents
27
+ - `Graph#depth(item)` — maximum dependency depth for a node
28
+
29
+ ## [0.1.8] - 2026-03-31
30
+
31
+ ### Added
32
+ - Add GitHub issue templates, dependabot config, and PR template
33
+
10
34
  ## [0.1.7] - 2026-03-31
11
35
 
12
36
  ### Changed
data/README.md CHANGED
@@ -65,6 +65,43 @@ graph.cycle? # => true
65
65
  graph.cycles # => [[:a, :b, :a]]
66
66
  ```
67
67
 
68
+ ### Dependency Queries
69
+
70
+ ```ruby
71
+ graph = Philiprehberger::DependencyGraph.new
72
+ graph.add(:a)
73
+ graph.add(:b, depends_on: [:a])
74
+ graph.add(:c, depends_on: [:b])
75
+ graph.add(:d, depends_on: [:b, :c])
76
+
77
+ graph.dependencies_of(:d) # => [:b, :c]
78
+ graph.all_dependencies_of(:d) # => [:b, :c, :a]
79
+ graph.dependents_of(:b) # => [:c, :d]
80
+ ```
81
+
82
+ ### Path Finding
83
+
84
+ ```ruby
85
+ graph.path(:d, :a) # => [:d, :b, :a]
86
+ graph.path(:a, :d) # => nil (no path in that direction)
87
+ ```
88
+
89
+ ### Subgraph Extraction
90
+
91
+ ```ruby
92
+ sub = graph.subgraph(:a, :b, :c)
93
+ sub.resolve # => [:a, :b, :c]
94
+ # Edges to nodes outside the subgraph are excluded
95
+ ```
96
+
97
+ ### Roots, Leaves, and Depth
98
+
99
+ ```ruby
100
+ graph.roots # => [:a] (no dependencies)
101
+ graph.leaves # => [:d] (nothing depends on it)
102
+ graph.depth(:d) # => 2 (longest path from a root)
103
+ ```
104
+
68
105
  ### Chaining
69
106
 
70
107
  ```ruby
@@ -83,6 +120,17 @@ graph.resolve # => [:a, :b, :c]
83
120
  | `Graph#parallel_batches` | Group into parallel execution batches |
84
121
  | `Graph#cycle?` | Check if the graph contains cycles |
85
122
  | `Graph#cycles` | List all detected cycles |
123
+ | `Graph#dependencies_of(item)` | Direct dependencies of an item |
124
+ | `Graph#all_dependencies_of(item)` | All transitive dependencies |
125
+ | `Graph#dependents_of(item)` | Items that directly depend on an item |
126
+ | `Graph#path(from, to)` | Shortest dependency path (BFS), or nil |
127
+ | `Graph#subgraph(*items)` | Extract a new graph with specified nodes |
128
+ | `Graph#roots` | Nodes with no dependencies |
129
+ | `Graph#leaves` | Nodes with no dependents |
130
+ | `Graph#depth(item)` | Maximum dependency depth of a node |
131
+ | `Graph#reverse` | Return a new graph with all edges flipped |
132
+ | `Graph#all_dependents_of(item)` | All transitive dependents of a node |
133
+ | `Graph#independent?(a, b)` | Whether two nodes are mutually unreachable |
86
134
 
87
135
  ## Development
88
136
 
@@ -105,8 +105,253 @@ module Philiprehberger
105
105
  found_cycles
106
106
  end
107
107
 
108
+ # Return direct dependencies of an item
109
+ #
110
+ # @param item [Object] the item to query
111
+ # @return [Array] direct dependencies, or empty array if item is unknown
112
+ def dependencies_of(item)
113
+ (@nodes[item] || []).dup
114
+ end
115
+
116
+ # Return all transitive dependencies of an item (direct + indirect)
117
+ #
118
+ # @param item [Object] the item to query
119
+ # @return [Array] all dependencies in no particular order
120
+ def all_dependencies_of(item)
121
+ return [] unless @nodes.key?(item)
122
+
123
+ visited = {}
124
+ queue = @nodes[item].dup
125
+ result = []
126
+
127
+ until queue.empty?
128
+ dep = queue.shift
129
+ next if visited[dep]
130
+
131
+ visited[dep] = true
132
+ result << dep
133
+ queue.concat(@nodes[dep] || [])
134
+ end
135
+
136
+ result
137
+ end
138
+
139
+ # Return items that directly depend on the given item (reverse lookup)
140
+ #
141
+ # @param item [Object] the item to query
142
+ # @return [Array] direct dependents
143
+ def dependents_of(item)
144
+ @nodes.each_with_object([]) do |(node, deps), acc|
145
+ acc << node if deps.include?(item)
146
+ end
147
+ end
148
+
149
+ # Find shortest dependency path between two nodes using BFS
150
+ #
151
+ # @param from [Object] the starting node
152
+ # @param to [Object] the target node
153
+ # @return [Array, nil] array of nodes forming the path, or nil if no path exists
154
+ def path(from, to)
155
+ return nil unless @nodes.key?(from) && @nodes.key?(to)
156
+ return [from] if from == to
157
+
158
+ visited = { from => nil }
159
+ queue = [from]
160
+
161
+ until queue.empty?
162
+ current = queue.shift
163
+ (@nodes[current] || []).each do |dep|
164
+ next if visited.key?(dep)
165
+
166
+ visited[dep] = current
167
+ if dep == to
168
+ return build_path(visited, from, to)
169
+ end
170
+
171
+ queue << dep
172
+ end
173
+ end
174
+
175
+ nil
176
+ end
177
+
178
+ # Extract a subgraph containing only the specified nodes and edges between them
179
+ #
180
+ # @param items [Array<Object>] nodes to include
181
+ # @return [Graph] a new graph with only the specified nodes
182
+ def subgraph(*items)
183
+ item_set = items.flatten.to_h { |i| [i, true] }
184
+ new_graph = self.class.new
185
+
186
+ items.flatten.each do |item|
187
+ next unless @nodes.key?(item)
188
+
189
+ matching_deps = (@nodes[item] || []).select { |dep| item_set[dep] }
190
+ new_graph.add(item, depends_on: matching_deps)
191
+ end
192
+
193
+ new_graph
194
+ end
195
+
196
+ # Return nodes that have no dependencies
197
+ #
198
+ # @return [Array] root nodes
199
+ def roots
200
+ @nodes.select { |_node, deps| deps.empty? }.keys
201
+ end
202
+
203
+ # Return nodes with no dependents (no other node depends on them)
204
+ #
205
+ # @return [Array] leaf nodes
206
+ def leaves
207
+ depended_on = @nodes.values.flatten.uniq
208
+ @nodes.keys.reject { |node| depended_on.include?(node) }
209
+ end
210
+
211
+ # Merge another graph into this one, combining nodes and dependencies
212
+ #
213
+ # @param other [Graph] another graph to merge
214
+ # @return [self]
215
+ def merge(other)
216
+ raise Error, 'Can only merge Graph instances' unless other.is_a?(self.class)
217
+
218
+ other.nodes.each do |node, deps|
219
+ @nodes[node] ||= []
220
+ deps.each do |dep|
221
+ @nodes[node] << dep unless @nodes[node].include?(dep)
222
+ end
223
+ end
224
+ self
225
+ end
226
+
227
+ # Remove a node and all edges referencing it
228
+ #
229
+ # @param item [Object] the item to remove
230
+ # @return [Boolean] true if the node existed, false otherwise
231
+ def remove(item)
232
+ return false unless @nodes.key?(item)
233
+
234
+ @nodes.delete(item)
235
+ @nodes.each_value { |deps| deps.delete(item) }
236
+ true
237
+ end
238
+
239
+ # Total number of nodes in the graph
240
+ #
241
+ # @return [Integer]
242
+ def size
243
+ @nodes.size
244
+ end
245
+
246
+ # Whether the graph has no nodes
247
+ #
248
+ # @return [Boolean]
249
+ def empty?
250
+ @nodes.empty?
251
+ end
252
+
253
+ # Return a new graph with all edges reversed (dependents become dependencies)
254
+ #
255
+ # @return [Graph] a new graph where each edge direction is flipped
256
+ def reverse
257
+ new_graph = self.class.new
258
+ @nodes.each_key { |node| new_graph.instance_variable_get(:@nodes)[node] ||= [] }
259
+ @nodes.each do |node, deps|
260
+ deps.each { |dep| new_graph.add(dep, depends_on: [node]) }
261
+ end
262
+ new_graph
263
+ end
264
+
265
+ # Return all transitive dependents of an item (direct + indirect)
266
+ #
267
+ # @param item [Object] the item to query
268
+ # @return [Array] all items that depend on this item, directly or transitively
269
+ def all_dependents_of(item)
270
+ return [] unless @nodes.key?(item)
271
+
272
+ visited = {}
273
+ queue = dependents_of(item)
274
+ result = []
275
+
276
+ until queue.empty?
277
+ dep = queue.shift
278
+ next if visited[dep]
279
+
280
+ visited[dep] = true
281
+ result << dep
282
+ queue.concat(dependents_of(dep))
283
+ end
284
+
285
+ result
286
+ end
287
+
288
+ # Check whether two nodes are independent (neither depends on the other transitively)
289
+ #
290
+ # @param node_a [Object]
291
+ # @param node_b [Object]
292
+ # @return [Boolean] true if neither node is reachable from the other
293
+ def independent?(node_a, node_b)
294
+ return false if node_a == node_b
295
+ return false unless @nodes.key?(node_a) && @nodes.key?(node_b)
296
+
297
+ !all_dependencies_of(node_a).include?(node_b) &&
298
+ !all_dependencies_of(node_b).include?(node_a)
299
+ end
300
+
301
+ # Export the graph in Graphviz DOT format
302
+ #
303
+ # @param name [String] the digraph name
304
+ # @return [String] DOT source
305
+ def to_dot(name: 'G')
306
+ lines = ["digraph #{name} {"]
307
+ @nodes.each_key { |node| lines << " #{dot_quote(node)};" }
308
+ @nodes.each do |node, deps|
309
+ deps.each { |dep| lines << " #{dot_quote(node)} -> #{dot_quote(dep)};" }
310
+ end
311
+ lines << '}'
312
+ lines.join("\n")
313
+ end
314
+
315
+ # Calculate maximum dependency depth for a node (longest path from any root to this node)
316
+ #
317
+ # @param item [Object] the item to query
318
+ # @return [Integer] the depth, or 0 if the item is a root or unknown
319
+ def depth(item)
320
+ return 0 unless @nodes.key?(item)
321
+
322
+ memo = {}
323
+ compute_depth(item, memo)
324
+ end
325
+
108
326
  private
109
327
 
328
+ def dot_quote(node)
329
+ %("#{node.to_s.gsub('"', '\"')}")
330
+ end
331
+
332
+ def build_path(visited, from, to)
333
+ path = [to]
334
+ current = to
335
+ while current != from
336
+ current = visited[current]
337
+ path.unshift(current)
338
+ end
339
+ path
340
+ end
341
+
342
+ def compute_depth(item, memo)
343
+ return memo[item] if memo.key?(item)
344
+
345
+ deps = @nodes[item] || []
346
+ memo[item] = if deps.empty?
347
+ 0
348
+ else
349
+ deps.map { |dep| compute_depth(dep, memo) }.max + 1
350
+ end
351
+
352
+ memo[item]
353
+ end
354
+
110
355
  def detect_cycles(node, visited, stack, path, found_cycles)
111
356
  visited[node] = true
112
357
  stack[node] = true
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module DependencyGraph
5
- VERSION = '0.1.7'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,17 +1,18 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-dependency_graph
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-31 00:00:00.000000000 Z
11
+ date: 2026-04-09 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Build and resolve dependency graphs using topological sort, detect cycles,
14
- and generate parallel execution batches for concurrent task scheduling.
14
+ generate parallel execution batches, query dependencies and dependents, find shortest
15
+ paths, and extract subgraphs.
15
16
  email:
16
17
  - me@philiprehberger.com
17
18
  executables: []
@@ -24,11 +25,11 @@ files:
24
25
  - lib/philiprehberger/dependency_graph.rb
25
26
  - lib/philiprehberger/dependency_graph/graph.rb
26
27
  - lib/philiprehberger/dependency_graph/version.rb
27
- homepage: https://github.com/philiprehberger/rb-dependency-graph
28
+ homepage: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-dependency_graph
28
29
  licenses:
29
30
  - MIT
30
31
  metadata:
31
- homepage_uri: https://github.com/philiprehberger/rb-dependency-graph
32
+ homepage_uri: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-dependency_graph
32
33
  source_code_uri: https://github.com/philiprehberger/rb-dependency-graph
33
34
  changelog_uri: https://github.com/philiprehberger/rb-dependency-graph/blob/main/CHANGELOG.md
34
35
  bug_tracker_uri: https://github.com/philiprehberger/rb-dependency-graph/issues