chaos_detector 0.4.9

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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/bin/detect_chaos +31 -0
  3. data/lib/chaos_detector.rb +22 -0
  4. data/lib/chaos_detector/chaos_graphs/chaos_edge.rb +32 -0
  5. data/lib/chaos_detector/chaos_graphs/chaos_graph.rb +389 -0
  6. data/lib/chaos_detector/chaos_graphs/domain_metrics.rb +19 -0
  7. data/lib/chaos_detector/chaos_graphs/domain_node.rb +57 -0
  8. data/lib/chaos_detector/chaos_graphs/function_node.rb +112 -0
  9. data/lib/chaos_detector/chaos_graphs/module_node.rb +86 -0
  10. data/lib/chaos_detector/chaos_utils.rb +57 -0
  11. data/lib/chaos_detector/graph_theory/appraiser.rb +162 -0
  12. data/lib/chaos_detector/graph_theory/edge.rb +76 -0
  13. data/lib/chaos_detector/graph_theory/graph.rb +144 -0
  14. data/lib/chaos_detector/graph_theory/loop_detector.rb +32 -0
  15. data/lib/chaos_detector/graph_theory/node.rb +70 -0
  16. data/lib/chaos_detector/graph_theory/node_metrics.rb +68 -0
  17. data/lib/chaos_detector/graph_theory/reduction.rb +40 -0
  18. data/lib/chaos_detector/graphing/directed_graphs.rb +396 -0
  19. data/lib/chaos_detector/graphing/graphs.rb +129 -0
  20. data/lib/chaos_detector/graphing/matrix_graphs.rb +101 -0
  21. data/lib/chaos_detector/navigator.rb +237 -0
  22. data/lib/chaos_detector/options.rb +51 -0
  23. data/lib/chaos_detector/stacker/comp_info.rb +42 -0
  24. data/lib/chaos_detector/stacker/fn_info.rb +44 -0
  25. data/lib/chaos_detector/stacker/frame.rb +34 -0
  26. data/lib/chaos_detector/stacker/frame_stack.rb +63 -0
  27. data/lib/chaos_detector/stacker/mod_info.rb +24 -0
  28. data/lib/chaos_detector/tracker.rb +276 -0
  29. data/lib/chaos_detector/utils/core_util.rb +117 -0
  30. data/lib/chaos_detector/utils/fs_util.rb +49 -0
  31. data/lib/chaos_detector/utils/lerp_util.rb +20 -0
  32. data/lib/chaos_detector/utils/log_util.rb +45 -0
  33. data/lib/chaos_detector/utils/str_util.rb +90 -0
  34. data/lib/chaos_detector/utils/tensor_util.rb +21 -0
  35. data/lib/chaos_detector/walkman.rb +214 -0
  36. metadata +147 -0
@@ -0,0 +1,76 @@
1
+ module ChaosDetector
2
+ module GraphTheory
3
+ class Edge
4
+ attr_accessor :edge_type
5
+ attr_writer :graph_props
6
+ attr_accessor :src_node
7
+ attr_accessor :dep_node
8
+ attr_accessor :reduction
9
+
10
+ EDGE_TYPES = {
11
+ default: 0,
12
+ superclass: 1,
13
+ association: 2,
14
+ class_association: 3
15
+ }.freeze
16
+
17
+ def initialize(src_node, dep_node, edge_type: :default, reduction: nil)
18
+ raise ArgumentError, 'src_node is required ' unless src_node
19
+ raise ArgumentError, 'dep_node is required ' unless dep_node
20
+
21
+ @src_node = src_node
22
+ @dep_node = dep_node
23
+ @reduction = reduction
24
+ @edge_type = edge_type
25
+ @graph_props = {}
26
+ end
27
+
28
+ def edge_rank
29
+ EDGE_TYPES.fetch(@edge_type, 0)
30
+ end
31
+
32
+ def weight
33
+ @reduction&.reduction_sum || 1
34
+ end
35
+
36
+ def hash
37
+ [@src_node, @dep_node].hash
38
+ end
39
+
40
+ def eql?(other)
41
+ self == other
42
+ end
43
+
44
+ # Default behavior is accessor for @graph_props
45
+ def graph_props
46
+ @graph_props
47
+ end
48
+
49
+
50
+
51
+ def ==(other)
52
+ # puts "Checking src and dep"
53
+ src_node == other.src_node && dep_node == other.dep_node
54
+ end
55
+
56
+ def to_s
57
+ s = format('[%s] -> [%s]', src_node.title, dep_node.title)
58
+ s << "(#{reduction.reduction_sum})" if reduction&.reduction_sum.to_i > 1
59
+ s
60
+ end
61
+
62
+ # Mutate this Edge; combining attributes from other:
63
+ def merge!(other)
64
+ raise ArgumentError, ('Argument other should be Edge object (was %s)' % other.class) unless other.is_a?(Edge)
65
+
66
+ if EDGE_TYPES.dig(other.edge_type) > EDGE_TYPES.dig(edge_type)
67
+ @edge_type = other.edge_type
68
+ end
69
+
70
+ # puts("EDGE REDUCTION: #{@reduction.class} -- #{other.class} // #{other.reduction.class}")
71
+ @reduction = ChaosDetector::GraphTheory::Reduction.combine(@reduction, other.reduction)
72
+ self
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,144 @@
1
+ # Maintains all nodes and edges as stack calls are pushed and popped via Frames.
2
+ module ChaosDetector
3
+ module GraphTheory
4
+ class Graph
5
+ attr_reader :root_node
6
+ attr_reader :edges
7
+
8
+ def node_count
9
+ @nodes.length
10
+ end
11
+
12
+ def edge_count
13
+ @edges.length
14
+ end
15
+
16
+ def initialize(root_node:, nodes: nil, edges: nil)
17
+ # raise ArgumentError, 'Root node required.' unless root_node
18
+
19
+ @root_node = root_node
20
+ @nodes = nodes || []
21
+ @edges = edges || []
22
+ end
23
+
24
+ def nodes(include_root: true)
25
+ include_root ? @nodes : @nodes.reject(&:root?)
26
+ end
27
+
28
+ # Return a new Graph object that only includes the given nodes and matching edges:
29
+ # def arrange_with(nodes:, include_root: true)
30
+ # gnodes = nodes.map(&:clone)
31
+ # if include_root
32
+ # gnodes << root_node unless(gnodes.include?(root_node))
33
+ # else
34
+ # gnodes.delete(root_node)
35
+ # end
36
+ #
37
+ # gedges = edges.filter do |edge|
38
+ # gnodes.include?(edge.src_node) && gnodes.include?(edge.dep_node)
39
+ # end
40
+ #
41
+ # ChaosDetector::GraphTheory::Graph.new(
42
+ # root_node: root_node,
43
+ # nodes: gnodes,
44
+ # edges: gedges
45
+ # ).tap do |graph|
46
+ # graph.whack_node(root_node) unless include_root
47
+ # end
48
+ # end
49
+
50
+ # Remove node and src and dep edges:
51
+ def whack_node(node)
52
+ @edges.delete_if { |e| e.src_node == node || e.dep_node == node }
53
+ @nodes.delete(node)
54
+ end
55
+
56
+ def traversal
57
+ to_enum(:traverse).map(&:itself) # {|n| puts "TNode:#{n}"; n.label}
58
+ end
59
+
60
+ # Possibly useful:
61
+ # def traversal(loop_detector: nil)
62
+ # trace_nodes = []
63
+
64
+ # traverse do |node|
65
+ # if loop_detector.nil? || loop_detector.tolerates?(trace_nodes, node)
66
+ # trace_nodes << node
67
+ # end
68
+ # end
69
+
70
+ # trace_nodes
71
+ # end
72
+
73
+ ### Depth-first traversal
74
+ # Consumes each edge as it used
75
+ def traverse(origin_node: nil)
76
+ raise ArgumentError, 'traverse requires block' unless block_given?
77
+ edges = @edges
78
+ nodes_to_visit = [origin_node || root_node]
79
+ while nodes_to_visit.length > 0
80
+ node = nodes_to_visit.shift
81
+ yield(node)
82
+ out_edges, edges = edges.partition { |e| e.src_node == node }
83
+ child_nodes = out_edges.map(&:dep_node)
84
+ nodes_to_visit = child_nodes + nodes_to_visit
85
+ end
86
+ end
87
+
88
+ def children(node)
89
+ @edges.select { |e| e.src_node == node }.map(&:dep_node).inject do |child_nodes|
90
+ puts "Found children for #{node.label}: #{child_nodes}"
91
+ end
92
+ end
93
+
94
+ def node_for(obj)
95
+ raise ArgumentError, '#node_for requires obj' unless obj
96
+
97
+ node_n = @nodes.index(obj)
98
+ if node_n
99
+ @nodes[node_n]
100
+ else
101
+ yield.tap { |n| @nodes << n }
102
+ end
103
+ end
104
+
105
+ def edges_for_node(node)
106
+ edges.filter do |e|
107
+ e.src_node == node || e.dep_node == node
108
+ end
109
+ end
110
+
111
+ def edge_for_nodes(src_node, dep_node)
112
+ # puts "EEEDGE_FOR_NODES::: #{src_node.to_s} / #{dep_node.class.to_s}"
113
+ edge = edges.find do |e|
114
+ e.src_node == src_node && e.dep_node == dep_node
115
+ end
116
+
117
+ edges << edge = ChaosDetector::GraphTheory::Edge.new(src_node, dep_node) if edge.nil?
118
+
119
+ edge
120
+ end
121
+
122
+ def ==(other)
123
+ root_node == other.root_node &&
124
+ nodes == other.nodes &&
125
+ edges == other.edges
126
+ end
127
+
128
+ def to_s
129
+ format('Nodes: %d, Edges: %d', @nodes.length, @edges.length)
130
+ end
131
+
132
+ def inspect
133
+ buffy = []
134
+ buffy << "\tNodes (#{nodes.length})"
135
+ buffy.concat(nodes.map { |n| "\t\t#{n.title}"})
136
+
137
+ # buffy << "Edges (#{@edges.length})"
138
+ # buffy.concat(@edges.map {|e|"\t\t#{e.to_s}"})
139
+
140
+ buffy.join("\n")
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,32 @@
1
+ # Maintains all nodes and edges as stack calls are pushed and popped via Frames.
2
+ module ChaosDetector
3
+ module GraphTheory
4
+ class LoopDetector
5
+ def initialize(detection: :simple, lookback: 0, tolerance: 0, grace_period: 0)
6
+ @detection = detection
7
+ @lookback = lookback
8
+ @tolerance = tolerance
9
+ @grace_period = grace_period
10
+ end
11
+
12
+ def tolerates?(nodes, node)
13
+ return true if (nodes.length <= @grace_period)
14
+ # return false if (lookback.zero? && tolerance.zero? && nodes.include?(node))
15
+
16
+ # TODO: lookback
17
+ nodes.count(node) > tolerance
18
+
19
+ end
20
+
21
+ private
22
+ def form_lookback
23
+ end
24
+
25
+ class << self
26
+ def simple
27
+ @simple ||= LoopDetector.new
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,70 @@
1
+ require 'chaos_detector/utils/str_util'
2
+ require 'chaos_detector/chaos_utils'
3
+
4
+ module ChaosDetector
5
+ module GraphTheory
6
+ class Node
7
+ ROOT_NODE_NAME = 'ROOT'.freeze
8
+
9
+ attr_writer :graph_props
10
+ attr_reader :is_root
11
+ attr_accessor :node_origin
12
+ attr_reader :reduction
13
+
14
+ def root?
15
+ !!is_root
16
+ end
17
+
18
+ def initialize(name: nil, root: false, node_origin: nil, reduction: nil)
19
+ raise ArgumentError, "Must have name or be root (name=#{name})" unless ChaosUtils.aught?(name) || root
20
+
21
+ @is_root = root
22
+ @name = @is_root ? ROOT_NODE_NAME : name
23
+ @node_origin = node_origin
24
+ @reduction = reduction
25
+ @graph_props = {}
26
+ end
27
+
28
+ def ==(other)
29
+ name == other.name &&
30
+ is_root == other.is_root
31
+ end
32
+
33
+ # Should be a reusable unique hash key for node:
34
+ def to_k
35
+ ChaosDetector::Utils::StrUtil.snakeize(name)
36
+ end
37
+
38
+ def to_s(_scope=nil)
39
+ name
40
+ end
41
+
42
+ def name
43
+ @is_root ? ROOT_NODE_NAME : @name
44
+ end
45
+
46
+ def title
47
+ name
48
+ end
49
+
50
+ def subtitle
51
+ nil
52
+ end
53
+
54
+ def root?
55
+ !!is_root
56
+ end
57
+
58
+ # Default behavior is accessor for @graph_props
59
+ def graph_props
60
+ @graph_props
61
+ end
62
+
63
+ # Mutate this Edge; combining attributes from other:
64
+ def merge!(other)
65
+ @reduction = ChaosDetector::GraphTheory::Reduction.combine(@reduction, other.reduction)
66
+ self
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,68 @@
1
+ require 'forwardable'
2
+
3
+ module ChaosDetector
4
+ module GraphTheory
5
+ class NodeMetrics
6
+ GRAPH_PROPERTIES = %i[ total_couplings afference efference instability ].freeze
7
+
8
+ extend Forwardable
9
+ attr_accessor :afference
10
+ attr_accessor :efference
11
+ attr_accessor :terminal_routes
12
+ attr_accessor :circular_routes
13
+ attr_reader :node
14
+
15
+ def_delegators :@node, :title, :subtitle
16
+
17
+ def reduction_sum
18
+ @node&.reduction&.reduction_sum || 1
19
+ end
20
+
21
+ def reduction_count
22
+ @node&.reduction&.reduction_count || 1
23
+ end
24
+
25
+ def initialize(node, afference: 0, efference: 0, terminal_routes:, circular_routes:)
26
+ raise ArgumentError("node is required") if node.nil?
27
+
28
+ @node = node
29
+ @afference = afference
30
+ @efference = efference
31
+ @terminal_routes = terminal_routes || []
32
+ @circular_routes = circular_routes || []
33
+ end
34
+
35
+ # https://en.wikipedia.org/wiki/Software_package_metrics
36
+ # I = Ce / (Ce + Ca).
37
+ # I = efference / (total couplings)
38
+ # Value from 0.0 to 1.0
39
+ # I = 0.0 is maximally stable while
40
+ # I = 1.0 is maximally unstable.
41
+ def instability
42
+ cT = total_couplings.to_f
43
+ (cT.zero?) ? 0.0 : @efference / cT
44
+ end
45
+
46
+ def total_couplings
47
+ @afference + @efference
48
+ end
49
+
50
+ def summary
51
+ 'I = Ce / (Ce + Ca)'
52
+ end
53
+
54
+ def to_h
55
+ {
56
+ afference: afference,
57
+ efference: efference,
58
+ instability: instability.round(2),
59
+ total_couplings: total_couplings
60
+ }
61
+ end
62
+
63
+ def to_s
64
+ "Ce: #{@efference}, Ca: #{@afference}, I: #{instability}"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,40 @@
1
+ # Trackking of reduction(merging/removing dups)
2
+ require 'set'
3
+ module ChaosDetector
4
+ module GraphTheory
5
+ class Reduction
6
+ attr_reader :reduction_count
7
+ attr_reader :reduction_sum
8
+
9
+ def initialize(reduction_count: 1, reduction_sum: 1)
10
+ @reduction_count = reduction_count
11
+ @reduction_sum = reduction_sum
12
+ end
13
+
14
+ def merge!(other)
15
+ @reduction_sum += (other&.reduction_count || 1)
16
+ @reduction_count += 1 #(other&.reduction_count || 1)
17
+ self
18
+ end
19
+
20
+ def to_s
21
+ 'Reduction (count/sum)=(%d, %d)' % [reduction_count, reduction_sum]
22
+ end
23
+
24
+ class << self
25
+ def combine(primary, secondary)
26
+ raise ArgumentError, ('Argument #primary should be Reduction object (was %s)' % primary.class) unless primary.is_a?(ChaosDetector::GraphTheory::Reduction)
27
+ # raise ArgumentError, ('Argument #secondary should be Reduction object (was %s)' % secondary.class) unless secondary.is_a?(ChaosDetector::GraphTheory::Reduction)
28
+
29
+ combined = primary ? primary.clone(freeze: false) : ChaosDetector::GraphTheory::Reduction.new
30
+ combined.merge!(secondary)
31
+ end
32
+
33
+ def combine_all(reductions)
34
+ red_sum = reductions.reduce(0) { |tally, r| tally + (r ? r.reduction_count : 1) }
35
+ Reduction.new(reduction_count: reductions.count, reduction_sum: red_sum)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,396 @@
1
+ require 'ruby-graphviz'
2
+ require 'chaos_detector/utils/str_util'
3
+ require 'chaos_detector/chaos_utils'
4
+
5
+ module ChaosDetector
6
+ module Graphing
7
+ class DirectedGraphs
8
+ attr_reader :root_graph
9
+ attr_reader :node_hash
10
+ attr_reader :cluster_node_hash
11
+ attr_reader :render_folder
12
+ attr_reader :rendered_path
13
+ attr_reader :edges
14
+
15
+ BR = '<BR/>'
16
+ LF = "\r"
17
+ EDGE_MIN = 0.75
18
+ EDGE_BASELINE = 10.5
19
+
20
+ CLR_BLACK = 'black'.freeze
21
+ CLR_DARKRED = 'red4'.freeze
22
+ CLR_DARKGREEN = 'darkgreen'.freeze
23
+ CLR_BRIGHTGREEN = 'yellowgreen'.freeze
24
+ CLR_CYAN = 'cyan'.freeze
25
+ CLR_GREY = 'snow3'.freeze
26
+ CLR_ORANGE = 'orange'.freeze
27
+ CLR_NICEGREY = 'snow4'.freeze
28
+ CLR_PALEGREEN = 'palegreen'.freeze
29
+ CLR_PINK = 'deeppink1'.freeze
30
+ CLR_PURPLE = '#662D91'.freeze
31
+ CLR_SLATE = '#778899'.freeze
32
+ CLR_WHITE = 'white'.freeze
33
+ CLR_BLUE = 'darkblue'.freeze
34
+
35
+ GRAPH_ATTRS = {
36
+ type: :digraph,
37
+ bgcolor: CLR_SLATE,
38
+ center: 'false',
39
+ clusterrank: 'local',
40
+ color: CLR_WHITE,
41
+ compound: 'true',
42
+ # concentrate: 'true',
43
+ # engine: 'dot',
44
+ fontcolor: CLR_WHITE,
45
+ fontname: 'Georgia',
46
+ fontsize: '48',
47
+ labelloc: 't',
48
+ labeljust: 'l',
49
+ # mindist: '0.5',
50
+ # nojustify: 'true',
51
+ pad: '0.5',
52
+ # pack: 'true',
53
+ # packmode: 'graph',
54
+ pencolor: CLR_WHITE,
55
+ # outputorder: 'edgesfirst',
56
+ # ordering: 'out',
57
+ outputorder: 'nodesfirst',
58
+ nodesep: '0.5',
59
+ # sep: '0.5',
60
+ newrank: 'false',
61
+ rankdir: 'LR',
62
+ ranksep: '1.5',
63
+ # ranksep: 'equally',
64
+ ratio: 'auto',
65
+ # size: '50',
66
+ # page: '50',
67
+ size: '34,44',
68
+ # splines: 'spline',
69
+ # strict: 'true'
70
+ }.freeze
71
+
72
+ SUBDOMAIN_ATTRS = {
73
+ pencolor: CLR_ORANGE,
74
+ bgcolor: CLR_NICEGREY,
75
+ fillcolor: CLR_NICEGREY,
76
+ fontsize: '24',
77
+ rank: 'same',
78
+ fontname: 'Verdana',
79
+ labelloc: 't',
80
+ margin: '32,32',
81
+ # pencolor: CLR_GREY,
82
+ penwidth: '2',
83
+ style: 'rounded'
84
+ }.freeze
85
+
86
+ NODE_ATTRS = {
87
+ color: CLR_WHITE,
88
+ fontname: 'Verdana',
89
+ fontsize: '12',
90
+ # fixedsize: 'shape',
91
+ # height: '2.0',
92
+ # width: '2.0',
93
+ # fillcolor: CLR_WHITE,
94
+ fontcolor: CLR_WHITE,
95
+ margin: '0.25, 0.125',
96
+ shape: 'egg',
97
+ }.freeze
98
+
99
+ EDGE_ATTRS = {
100
+ color:CLR_WHITE,
101
+ constraint: 'true',
102
+ dir:'forward',
103
+ fontname:'Verdana',
104
+ fontcolor:CLR_ORANGE,
105
+ fontsize:'16',
106
+ # minlen: '3.0',
107
+ style:'solid',
108
+ penwidth:'1.0',
109
+ }.freeze
110
+
111
+ STUB_NODE_ATTRS = {
112
+ fixedsize: 'true',
113
+ size: '0.5, 0.5',
114
+ style: 'invis',
115
+ }
116
+
117
+ # Status messages:
118
+ PRE_STATUS_MSG = %(
119
+ Will update %<count>d record types from [%<from_type>s] to [%<to_type>s]'
120
+ ).freeze
121
+
122
+ TBL_HTML = <<~HTML
123
+ <TABLE BORDER='0' CELLBORDER='1' CELLSPACING='0' CELLPADDING='4'>
124
+ %s
125
+ </TABLE>
126
+ HTML
127
+
128
+ TBL_ROW_HTML = %(<TR BGCOLOR="%<color>s">%<cells>s</TR>)
129
+ TBL_CELL_HTML = %(<TD>%s</TD>)
130
+ BOLD_HTML = %(<BOLD>%s</BOLD>)
131
+
132
+ # TODO: integrate options as needed:
133
+ def initialize(render_folder: nil)
134
+ @root_graph = nil
135
+ @node_hash = {}
136
+ @cluster_node_hash = {}
137
+ @edges = Set.new
138
+ @render_folder = render_folder || 'render'
139
+ end
140
+
141
+ def create_directed_graph(title, graph_attrs: nil)
142
+ @title = title
143
+
144
+ @node_hash.clear
145
+ @cluster_node_hash.clear
146
+ @cluster_node_hash.clear
147
+
148
+ lbl = title_html(@title, subtitle: graph_attrs&.dig(:subtitle))
149
+
150
+ attrs = {
151
+ label: "<#{lbl}>",
152
+ **GRAPH_ATTRS,
153
+ }
154
+ attrs.merge(graph_attrs) if graph_attrs&.any?
155
+ attrs.delete(:subtitle)
156
+
157
+ @root_graph = GraphViz.digraph(:G, attrs)
158
+ end
159
+
160
+ def node_label(node, metrics_table: false)
161
+ if metrics_table
162
+ tbl_hash = {title: node.title, subtitle: node.subtitle, **node.graph_props}
163
+ html = html_tbl_from(hash: tbl_hash) do |k, v|
164
+ if k==:title
165
+ [in_font(k, font_size: 24), in_font(v, font_size: 16)]#[BOLD_HTML % k, BOLD_HTML % v]
166
+ else
167
+ [k, v]
168
+ end
169
+ end
170
+ else
171
+ html = title_html(node.title, subtitle: node.subtitle)
172
+ end
173
+ html.strip!
174
+ # puts '_' * 50
175
+ # puts "html: #{html}"
176
+ '<%s>' % html
177
+ end
178
+
179
+ # HTML Label with subtitle:
180
+ def title_html(title, subtitle:nil, font_size:24, subtitle_fontsize:nil)
181
+ lbl_buf = [in_font(title, font_size: font_size)]
182
+
183
+ sub_fontsize = subtitle_fontsize || 3 * font_size / 4
184
+ if ChaosUtils.aught?(subtitle)
185
+ lbl_buf << in_font(subtitle, font_size: sub_fontsize)
186
+ end
187
+
188
+ # Fake out some padding:
189
+ lbl_buf << in_font(' ', font_size: sub_fontsize)
190
+
191
+ lbl_buf.join(BR)
192
+ end
193
+
194
+ def in_font(str, font_size:12)
195
+ "<FONT POINT-SIZE='#{font_size}'>#{str}</FONT>"
196
+ end
197
+
198
+ def html_tbl_from(hash:)
199
+ trs = hash.map.with_index do |h, n|
200
+ k, v = h
201
+ key_content, val_content = yield(k, v) if block_given?
202
+ key_td = TBL_CELL_HTML % (key_content || k)
203
+ val_td = TBL_CELL_HTML % (val_content || v)
204
+ td_html = [key_td, val_td].join
205
+ html = format(TBL_ROW_HTML, {
206
+ color: n.even? ? 'blue' : 'white',
207
+ cells: td_html.strip
208
+ })
209
+ html.strip
210
+ end
211
+
212
+ TBL_HTML % trs.join().strip
213
+ end
214
+
215
+ def assert_graph_state
216
+ raise '@root_graph is not set yet. Call create_directed_graph.' unless @root_graph
217
+ end
218
+
219
+ # Add node to given parent_node, assuming parent_node is a subgraph
220
+ def add_node_to_parent(node, parent_node:, as_cluster: false, metrics_table:false)
221
+ assert_graph_state
222
+ raise 'node is required' unless node
223
+
224
+ parent_graph = if parent_node
225
+ _clust, p_graph = find_graph_node(parent_node)
226
+ raise "Couldn't find parent node: #{parent_node}" unless p_graph
227
+ p_graph
228
+ else
229
+ @root_graph
230
+ end
231
+
232
+ add_node_to_graph(node, graph: parent_graph, as_cluster: as_cluster, metrics_table: metrics_table)
233
+ end
234
+
235
+ def add_node_to_graph(node, graph: nil, as_cluster: false, metrics_table:false)
236
+ assert_graph_state
237
+ raise 'node is required' unless node
238
+
239
+ parent_graph = graph || @root_graph
240
+ key = node.to_k
241
+
242
+ attrs = { label: node_label(node, metrics_table: metrics_table) }
243
+
244
+ if as_cluster
245
+ # tab good shape
246
+ subgraph_name = "cluster_#{key}"
247
+ attrs.merge!(SUBDOMAIN_ATTRS)
248
+ # attrs = {}.merge(SUBDOMAIN_ ATTRS)
249
+ @cluster_node_hash[key] = parent_graph.add_graph(subgraph_name, attrs)
250
+
251
+ @cluster_node_hash[key].add_nodes(node_key(node, cluster: :stub), STUB_NODE_ATTRS)
252
+
253
+ # @cluster_node_hash[key].attrs(attrs)
254
+ # puts ("attrs: #{key}: #{attrs} / #{@cluster_node_hash.length}")
255
+ else
256
+ attrs = attrs.merge!(NODE_ATTRS)
257
+ @node_hash[key] = parent_graph.add_nodes(key, attrs)
258
+ end
259
+ end
260
+
261
+ def append_nodes(nodes, as_cluster: false, metrics_table: false)
262
+ assert_graph_state
263
+ return unless nodes
264
+ # raise 'node is required' unless nodes
265
+
266
+ nodes.each do |node|
267
+ parent_node = block_given? ? yield(node) : nil
268
+ # puts "gotit #{parent_node}" if parent_node
269
+ add_node_to_parent(node, parent_node: parent_node, as_cluster: as_cluster, metrics_table: metrics_table)
270
+ end
271
+ end
272
+
273
+ def node_key(node, cluster: false)
274
+ if cluster==:stub
275
+ "cluster_stub_#{node.to_k}"
276
+ elsif !!cluster
277
+ "cluster_#{node.to_k}"
278
+ else
279
+ node.to_k
280
+ end
281
+ end
282
+
283
+ def add_edges(edges, calc_weight:true, node_safe:true)
284
+ assert_graph_state
285
+
286
+ # @node_hash.each do |k, v|
287
+ # log("NODE_HASH: Has value for #{ChaosUtils.decorate(k)} => #{ChaosUtils.decorate(v)}")
288
+ # end
289
+
290
+ weight_max = edges.map(&:weight).max
291
+
292
+ edges.each do |edge|
293
+ src_clust, src = find_graph_node(edge.src_node)
294
+ dep_clust, dep = find_graph_node(edge.dep_node)
295
+ next unless !node_safe || (src && dep)
296
+
297
+ @edges << [src, dep]
298
+ edge_attrs = build_edge_attrs(edge, calc_weight: calc_weight, max_weight: weight_max, src_clust: src_clust, dep_clust: dep_clust)
299
+
300
+ @root_graph.add_edges(
301
+ node_key(edge.src_node, cluster: src_clust ? :stub : false),
302
+ node_key(edge.dep_node, cluster: dep_clust ? :stub : false),
303
+ edge_attrs
304
+ ) # , {label: e.reduce_sum, penwidth: weight})
305
+ end
306
+ end
307
+
308
+ def render_graph
309
+ assert_graph_state
310
+
311
+ filename = "#{@title}.png"
312
+ @rendered_path = File.join(@render_folder, filename).to_s
313
+
314
+ log("Rendering graph to to #{@rendered_path}")
315
+ ChaosDetector::Utils::FSUtil.ensure_paths_to_file(@rendered_path)
316
+ @root_graph.output(png: @rendered_path)
317
+ self
318
+ end
319
+
320
+ private
321
+
322
+ def build_edge_attrs(edge, calc_weight: true, max_weight: nil, src_clust: nil, dep_clust: nil)
323
+ edge_attrs = EDGE_ATTRS.dup
324
+
325
+ # Edge attaches to cluster if possible:
326
+ edge_attrs[:ltail] = node_key(edge.src_node, cluster: true) if src_clust
327
+ edge_attrs[:lhead] = node_key(edge.dep_node, cluster: true) if dep_clust
328
+
329
+ # Proportional edge weight:
330
+ if calc_weight && max_weight
331
+ edge_attrs.merge!(
332
+ label: edge.weight,
333
+ penwidth: edge_weight(edge.weight / max_weight)
334
+ )
335
+ end
336
+
337
+ # Intra-domain:
338
+ if edge.src_node.domain_name == edge.dep_node.domain_name
339
+ edge_attrs.merge!(
340
+ style: 'dotted',
341
+ color: CLR_ORANGE,
342
+ constraint: 'true',
343
+ )
344
+ end
345
+
346
+ # Props for edge_type:
347
+ edge_attrs.merge!(
348
+ case edge.edge_type
349
+ when :superclass
350
+ {
351
+ arrowhead: 'empty',
352
+ arrowsize: 1.0,
353
+ color: CLR_BLUE
354
+ }
355
+ when :association
356
+ {
357
+ arrowhead: 'diamond',
358
+ arrowsize: 1.0,
359
+ color: CLR_ORANGE
360
+ }
361
+ when :class_association
362
+ {
363
+ arrowhead: 'diamond',
364
+ arrowsize: 1.0,
365
+ color: CLR_PINK
366
+ }
367
+ else
368
+ {
369
+ arrowhead: 'open',
370
+ arrowsize: 1.0
371
+ }
372
+ end
373
+ )
374
+ end
375
+
376
+ def find_graph_node(node)
377
+ assert_graph_state
378
+ # log("NODE_HASH: LOOKING UP #{ChaosUtils.decorate(node)}")
379
+ cnode = @cluster_node_hash[node.to_k]
380
+ if cnode
381
+ [true, cnode]
382
+ else
383
+ [false, @node_hash[node.to_k]]
384
+ end
385
+ end
386
+
387
+ def edge_weight(n, edge_min: EDGE_MIN, edge_baseline: EDGE_BASELINE)
388
+ edge_min + n * edge_baseline
389
+ end
390
+
391
+ def log(msg, **opts)
392
+ ChaosUtils.log_msg(msg, subject: 'DGraphDiagram', **opts)
393
+ end
394
+ end
395
+ end
396
+ end