Class: Rwm::DependencyGraph

Inherits:
Object
  • Object
show all
Includes:
TSort
Defined in:
lib/rwm/dependency_graph.rb

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeDependencyGraph

Returns a new instance of DependencyGraph.



13
14
15
16
17
# File 'lib/rwm/dependency_graph.rb', line 13

def initialize
  @packages = {}   # name => Package
  @edges = {}      # name => [dep_name, ...]
  @dependents = {} # name => [dependent_name, ...]
end

Instance Attribute Details

#edgesObject (readonly)

Returns the value of attribute edges.



11
12
13
# File 'lib/rwm/dependency_graph.rb', line 11

def edges
  @edges
end

#packagesObject (readonly)

Returns the value of attribute packages.



11
12
13
# File 'lib/rwm/dependency_graph.rb', line 11

def packages
  @packages
end

Class Method Details

.build(workspace) ⇒ Object

Build graph from a workspace by parsing all Gemfiles



176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/rwm/dependency_graph.rb', line 176

def self.build(workspace)
  graph = new

  workspace.packages.each { |pkg| graph.add_package(pkg) }

  workspace.packages.each do |pkg|
    deps = GemfileParser.parse(pkg.gemfile_path, workspace.packages)
    deps.each { |dep| graph.add_edge(pkg.name, dep.name) }
  end

  graph
end

.load(workspace) ⇒ Object

Load graph from cached .rwm/graph.json, falling back to build. Auto-rebuilds when any package Gemfile is newer than the cache.



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'lib/rwm/dependency_graph.rb', line 111

def self.load(workspace)
  path = workspace.graph_path
  unless File.exist?(path)
    Rwm.debug("graph: no cached graph found, building from scratch")
    return build_and_save(workspace)
  end

  if stale?(path, workspace.packages)
    Rwm.debug("graph: cached graph is stale, rebuilding")
    return build_and_save(workspace)
  end

  Rwm.debug("graph: loading from cache at #{path}")
  begin
    data = JSON.parse(read_locked(path))
  rescue Errno::ENOENT
    Rwm.debug("graph: cache file disappeared, rebuilding")
    return build_and_save(workspace)
  rescue JSON::ParserError
    Rwm.debug("graph: cache file contains invalid JSON, rebuilding")
    return build_and_save(workspace)
  end

  graph = new

  workspace.packages.each { |pkg| graph.add_package(pkg) }

  data["edges"]&.each do |name, deps|
    next unless graph.packages.key?(name)

    deps.each do |dep|
      if graph.packages.key?(dep)
        graph.add_edge(name, dep)
      else
        Rwm.debug("graph: skipping stale edge #{name} -> #{dep} (package removed)")
      end
    end
  end

  graph
end

Instance Method Details

#add_edge(from_name, to_name) ⇒ Object



25
26
27
28
29
30
# File 'lib/rwm/dependency_graph.rb', line 25

def add_edge(from_name, to_name)
  @edges[from_name] ||= []
  @edges[from_name] << to_name unless @edges[from_name].include?(to_name)
  @dependents[to_name] ||= []
  @dependents[to_name] << from_name unless @dependents[to_name].include?(from_name)
end

#add_package(package) ⇒ Object



19
20
21
22
23
# File 'lib/rwm/dependency_graph.rb', line 19

def add_package(package)
  @packages[package.name] = package
  @edges[package.name] ||= []
  @dependents[package.name] ||= []
end

#dependencies(name) ⇒ Object

Dependencies of a package (what it depends on)



33
34
35
# File 'lib/rwm/dependency_graph.rb', line 33

def dependencies(name)
  @edges[name] || []
end

#direct_dependents(name) ⇒ Object

Direct dependents of a package (what depends on it)



38
39
40
# File 'lib/rwm/dependency_graph.rb', line 38

def direct_dependents(name)
  @dependents[name] || []
end

#execution_levelsObject

Group packages into execution levels — packages at the same level have no interdependencies and can run in parallel



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/rwm/dependency_graph.rb', line 87

def execution_levels
  return [] if @packages.empty?

  remaining = @packages.keys.dup
  placed = Set.new
  levels = []

  until remaining.empty?
    level = remaining.select do |name|
      dependencies(name).all? { |dep| placed.include?(dep) }
    end

    raise CycleError, [["Unable to resolve execution levels — possible cycle"]] if level.empty?

    level.each { |name| placed.add(name) }
    levels << level.sort
    remaining -= level
  end

  levels
end

#save(path, workspace_root) ⇒ Object



201
202
203
204
205
# File 'lib/rwm/dependency_graph.rb', line 201

def save(path, workspace_root)
  dir = File.dirname(path)
  FileUtils.mkdir_p(dir)
  write_locked(path, JSON.pretty_generate(to_json_data(workspace_root: workspace_root)) + "\n")
end

#to_dotObject



207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/rwm/dependency_graph.rb', line 207

def to_dot
  lines = []
  lines << "digraph rwm {"
  lines << "  rankdir=LR;"
  lines << "  node [shape=box];"

  @packages.each_value do |pkg|
    lines << "  \"#{pkg.name}\" [label=\"#{pkg.name} (#{pkg.type})\"];"
  end

  @edges.each do |from, deps|
    deps.each do |to|
      lines << "  \"#{from}\" -> \"#{to}\";"
    end
  end

  lines << "}"
  lines.join("\n") + "\n"
end

#to_json_data(workspace_root: "") ⇒ Object

Serialize to JSON for .rwm/graph.json



190
191
192
193
194
195
196
197
198
199
# File 'lib/rwm/dependency_graph.rb', line 190

def to_json_data(workspace_root: "")
  {
    "version" => 1,
    "generated_at" => Time.now.iso8601,
    "packages" => @packages.transform_values do |pkg|
      { "name" => pkg.name, "type" => pkg.type.to_s, "path" => pkg.relative_path(workspace_root) }
    end,
    "edges" => @edges.transform_values(&:sort)
  }
end

#to_mermaidObject



227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
# File 'lib/rwm/dependency_graph.rb', line 227

def to_mermaid
  lines = []
  lines << "graph LR"

  @packages.each_value do |pkg|
    lines << "  #{pkg.name}[\"#{pkg.name} (#{pkg.type})\"]"
  end

  @edges.each do |from, deps|
    deps.each do |to|
      lines << "  #{from} --> #{to}"
    end
  end

  lines.join("\n") + "\n"
end

#topological_orderObject

Topological sort (dependencies before dependents)



79
80
81
82
83
# File 'lib/rwm/dependency_graph.rb', line 79

def topological_order
  tsort
rescue TSort::Cyclic => e
  raise CycleError, [[e.message]]
end

#transitive_dependencies(name) ⇒ Object

Walk the graph to find all transitive dependencies (what a package transitively depends on)



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/rwm/dependency_graph.rb', line 61

def transitive_dependencies(name)
  visited = Set.new
  queue = [name]

  until queue.empty?
    current = queue.shift
    dependencies(current).each do |dep|
      next if visited.include?(dep)

      visited << dep
      queue << dep
    end
  end

  visited.to_a
end

#transitive_dependents(name) ⇒ Object

Walk the graph to find all transitive dependents



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/rwm/dependency_graph.rb', line 43

def transitive_dependents(name)
  visited = Set.new
  queue = [name]

  until queue.empty?
    current = queue.shift
    direct_dependents(current).each do |dep|
      next if visited.include?(dep)

      visited << dep
      queue << dep
    end
  end

  visited.to_a
end