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.
@@ -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