blast_radius 0.1.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 +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +259 -0
- data/lib/blast_radius/active_record_extension.rb +62 -0
- data/lib/blast_radius/analyzer.rb +60 -0
- data/lib/blast_radius/configuration.rb +31 -0
- data/lib/blast_radius/dependency_tree.rb +87 -0
- data/lib/blast_radius/formatters/base.rb +28 -0
- data/lib/blast_radius/formatters/dot_formatter.rb +61 -0
- data/lib/blast_radius/formatters/html_formatter.rb +144 -0
- data/lib/blast_radius/formatters/json_formatter.rb +36 -0
- data/lib/blast_radius/formatters/mermaid_formatter.rb +49 -0
- data/lib/blast_radius/formatters/text_formatter.rb +43 -0
- data/lib/blast_radius/html/graph.js +852 -0
- data/lib/blast_radius/html/styles.css +475 -0
- data/lib/blast_radius/html/template.html.erb +112 -0
- data/lib/blast_radius/impact_calculator.rb +129 -0
- data/lib/blast_radius/node.rb +65 -0
- data/lib/blast_radius/railtie.rb +21 -0
- data/lib/blast_radius/version.rb +5 -0
- data/lib/blast_radius.rb +47 -0
- data/lib/tasks/blast_radius.rake +102 -0
- metadata +164 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module BlastRadius
|
|
7
|
+
module Formatters
|
|
8
|
+
class HtmlFormatter < Base
|
|
9
|
+
# Format dependency tree as interactive HTML ER diagram
|
|
10
|
+
# @param root_node [Node] root node of dependency tree
|
|
11
|
+
# @param options [Hash] formatting options
|
|
12
|
+
# - :all_models [Boolean] include all models in ER diagram (default: true)
|
|
13
|
+
# - :title [String] custom title
|
|
14
|
+
# - :theme [Symbol] :light, :dark, or :auto (default: :auto)
|
|
15
|
+
# @return [String] HTML representation
|
|
16
|
+
def format(root_node, options = {})
|
|
17
|
+
@root_node = root_node
|
|
18
|
+
@options = options
|
|
19
|
+
@all_models = discover_all_models
|
|
20
|
+
|
|
21
|
+
template_content = File.read(template_path)
|
|
22
|
+
erb = ERB.new(template_content)
|
|
23
|
+
erb.result(binding)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def template_path
|
|
29
|
+
File.join(__dir__, '../html/template.html.erb')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def css
|
|
33
|
+
File.read(File.join(__dir__, '../html/styles.css'))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def javascript
|
|
37
|
+
File.read(File.join(__dir__, '../html/graph.js'))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def title
|
|
41
|
+
@options[:title] || 'Dependency Analysis'
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def root_model
|
|
45
|
+
@root_node.model_name
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def total_models
|
|
49
|
+
@all_models.size
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def total_associations
|
|
53
|
+
graph_data[:edges].size
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def dependent_counts
|
|
57
|
+
counts = Hash.new(0)
|
|
58
|
+
graph_data[:edges].each do |edge|
|
|
59
|
+
counts[edge[:dependent]] += 1 if edge[:dependent]
|
|
60
|
+
end
|
|
61
|
+
counts
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def graph_data
|
|
65
|
+
@graph_data ||= build_graph_data
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def build_graph_data
|
|
69
|
+
nodes = []
|
|
70
|
+
edges = []
|
|
71
|
+
visited_edges = Set.new
|
|
72
|
+
|
|
73
|
+
# Build nodes from all models
|
|
74
|
+
@all_models.each do |model_class|
|
|
75
|
+
nodes << {
|
|
76
|
+
name: model_class.name,
|
|
77
|
+
table_name: model_class.table_name
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Build edges from all associations
|
|
81
|
+
analyzer = BlastRadius::Analyzer.new
|
|
82
|
+
associations = analyzer.analyze(model_class)
|
|
83
|
+
|
|
84
|
+
associations.each do |assoc|
|
|
85
|
+
edge_key = "#{model_class.name}->#{assoc[:class_name]}"
|
|
86
|
+
next if visited_edges.include?(edge_key)
|
|
87
|
+
|
|
88
|
+
edges << {
|
|
89
|
+
from: model_class.name,
|
|
90
|
+
to: assoc[:class_name],
|
|
91
|
+
association: assoc[:name].to_s,
|
|
92
|
+
dependent: assoc[:dependent].to_s,
|
|
93
|
+
type: assoc[:type].to_s
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
visited_edges.add(edge_key)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
nodes: nodes,
|
|
102
|
+
edges: edges
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def discover_all_models
|
|
107
|
+
if defined?(Rails)
|
|
108
|
+
begin
|
|
109
|
+
Rails.application.eager_load!
|
|
110
|
+
ApplicationRecord.descendants.select do |model|
|
|
111
|
+
model.name.present? && !excluded_model?(model.name)
|
|
112
|
+
end
|
|
113
|
+
rescue StandardError
|
|
114
|
+
# If Rails is not available or ApplicationRecord doesn't exist
|
|
115
|
+
ActiveRecord::Base.descendants.select do |model|
|
|
116
|
+
model.name.present? && !excluded_model?(model.name)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
else
|
|
120
|
+
# For non-Rails environments, use the root model's related models
|
|
121
|
+
discover_from_tree(@root_node)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def discover_from_tree(node, discovered = Set.new)
|
|
126
|
+
return discovered.to_a if discovered.include?(node.model_class)
|
|
127
|
+
|
|
128
|
+
discovered.add(node.model_class)
|
|
129
|
+
|
|
130
|
+
node.children.each do |child|
|
|
131
|
+
discover_from_tree(child, discovered)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
discovered.to_a
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def excluded_model?(model_name)
|
|
138
|
+
@configuration&.exclude_models&.any? do |pattern|
|
|
139
|
+
pattern.is_a?(Regexp) ? pattern.match?(model_name) : pattern == model_name
|
|
140
|
+
end || false
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module BlastRadius
|
|
6
|
+
module Formatters
|
|
7
|
+
class JsonFormatter < Base
|
|
8
|
+
# Format dependency tree as JSON
|
|
9
|
+
# @param root_node [Node] root node of dependency tree
|
|
10
|
+
# @return [String] JSON representation
|
|
11
|
+
def format(root_node)
|
|
12
|
+
JSON.pretty_generate(node_to_hash(root_node))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def node_to_hash(node)
|
|
18
|
+
hash = {
|
|
19
|
+
'model' => node.model_name
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
unless node.root?
|
|
23
|
+
hash['association'] = node.association_name.to_s
|
|
24
|
+
hash['type'] = node.association_type.to_s
|
|
25
|
+
hash['dependent'] = node.dependent_type.to_s
|
|
26
|
+
hash['through'] = node.through.to_s if node.through
|
|
27
|
+
hash['polymorphic'] = node.polymorphic if node.polymorphic
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
hash['children'] = node.children.map { |child| node_to_hash(child) } unless node.children.empty?
|
|
31
|
+
|
|
32
|
+
hash
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BlastRadius
|
|
4
|
+
module Formatters
|
|
5
|
+
class MermaidFormatter < Base
|
|
6
|
+
# Format dependency tree as Mermaid diagram
|
|
7
|
+
# @param root_node [Node] root node of dependency tree
|
|
8
|
+
# @return [String] Mermaid diagram syntax
|
|
9
|
+
def format(root_node)
|
|
10
|
+
@node_counter = 0
|
|
11
|
+
@node_ids = {}
|
|
12
|
+
lines = ['graph TD']
|
|
13
|
+
|
|
14
|
+
# Assign ID to root node
|
|
15
|
+
node_id(root_node)
|
|
16
|
+
|
|
17
|
+
# Process all nodes
|
|
18
|
+
format_node(root_node, lines)
|
|
19
|
+
|
|
20
|
+
lines.join("\n")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def format_node(node, lines)
|
|
26
|
+
current_id = node_id(node)
|
|
27
|
+
|
|
28
|
+
node.children.each do |child|
|
|
29
|
+
child_id = node_id(child)
|
|
30
|
+
association_label = "#{child.association_name}|#{child.dependent_type}"
|
|
31
|
+
|
|
32
|
+
# Create edge with label
|
|
33
|
+
lines << " #{current_id} -->|#{association_label}| #{child_id}[#{child.model_name}]"
|
|
34
|
+
|
|
35
|
+
# Recursively process children
|
|
36
|
+
format_node(child, lines) unless child.children.empty?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def node_id(node)
|
|
41
|
+
@node_ids[node] ||= if node.root?
|
|
42
|
+
node.model_name
|
|
43
|
+
else
|
|
44
|
+
"node#{@node_counter += 1}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BlastRadius
|
|
4
|
+
module Formatters
|
|
5
|
+
class TextFormatter < Base
|
|
6
|
+
TREE_BRANCH = '├── '
|
|
7
|
+
TREE_LAST = '└── '
|
|
8
|
+
TREE_VERTICAL = '│ '
|
|
9
|
+
TREE_SPACE = ' '
|
|
10
|
+
|
|
11
|
+
# Format dependency tree as ASCII tree
|
|
12
|
+
# @param root_node [Node] root node of dependency tree
|
|
13
|
+
# @return [String] ASCII tree representation
|
|
14
|
+
def format(root_node)
|
|
15
|
+
lines = [root_node.model_name]
|
|
16
|
+
format_children(root_node.children, '', lines)
|
|
17
|
+
lines.join("\n")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def format_children(children, prefix, lines)
|
|
23
|
+
children.each_with_index do |child, index|
|
|
24
|
+
is_last = (index == children.size - 1)
|
|
25
|
+
lines << format_node(child, prefix, is_last)
|
|
26
|
+
|
|
27
|
+
unless child.children.empty?
|
|
28
|
+
new_prefix = prefix + (is_last ? TREE_SPACE : TREE_VERTICAL)
|
|
29
|
+
format_children(child.children, new_prefix, lines)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def format_node(node, prefix, is_last)
|
|
35
|
+
branch = is_last ? TREE_LAST : TREE_BRANCH
|
|
36
|
+
dependent_label = "[#{node.dependent_type}]"
|
|
37
|
+
association_label = "#{node.association_name} (#{node.model_name})"
|
|
38
|
+
|
|
39
|
+
"#{prefix}#{branch}#{dependent_label} #{association_label}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|