rails-flow-map 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 +52 -0
- data/LICENSE +21 -0
- data/README.md +314 -0
- data/README_ja.md +314 -0
- data/README_zh.md +314 -0
- data/lib/rails_flow_map/analyzers/controller_analyzer.rb +124 -0
- data/lib/rails_flow_map/analyzers/model_analyzer.rb +139 -0
- data/lib/rails_flow_map/configuration.rb +22 -0
- data/lib/rails_flow_map/engine.rb +16 -0
- data/lib/rails_flow_map/errors.rb +240 -0
- data/lib/rails_flow_map/formatters/d3js_formatter.rb +488 -0
- data/lib/rails_flow_map/formatters/erd_formatter.rb +64 -0
- data/lib/rails_flow_map/formatters/git_diff_formatter.rb +589 -0
- data/lib/rails_flow_map/formatters/graphviz_formatter.rb +111 -0
- data/lib/rails_flow_map/formatters/mermaid_formatter.rb +91 -0
- data/lib/rails_flow_map/formatters/metrics_formatter.rb +196 -0
- data/lib/rails_flow_map/formatters/openapi_formatter.rb +557 -0
- data/lib/rails_flow_map/formatters/plantuml_formatter.rb +92 -0
- data/lib/rails_flow_map/formatters/sequence_formatter.rb +288 -0
- data/lib/rails_flow_map/generators/install/templates/rails_flow_map.rb +34 -0
- data/lib/rails_flow_map/generators/install_generator.rb +32 -0
- data/lib/rails_flow_map/logging.rb +215 -0
- data/lib/rails_flow_map/models/flow_edge.rb +31 -0
- data/lib/rails_flow_map/models/flow_graph.rb +58 -0
- data/lib/rails_flow_map/models/flow_node.rb +37 -0
- data/lib/rails_flow_map/version.rb +3 -0
- data/lib/rails_flow_map.rb +310 -0
- data/lib/tasks/rails_flow_map.rake +70 -0
- metadata +156 -0
@@ -0,0 +1,589 @@
|
|
1
|
+
require 'cgi'
|
2
|
+
|
3
|
+
module RailsFlowMap
|
4
|
+
# Compares two graph states and visualizes the differences
|
5
|
+
#
|
6
|
+
# This formatter analyzes changes between two versions of an application's
|
7
|
+
# architecture, highlighting additions, removals, and modifications. It can
|
8
|
+
# output in multiple formats including Mermaid, HTML, and plain text.
|
9
|
+
#
|
10
|
+
# @example Basic usage
|
11
|
+
# before = RailsFlowMap.analyze_at('main')
|
12
|
+
# after = RailsFlowMap.analyze
|
13
|
+
# formatter = GitDiffFormatter.new(before, after)
|
14
|
+
# diff = formatter.format
|
15
|
+
#
|
16
|
+
# @example HTML output with custom options
|
17
|
+
# formatter = GitDiffFormatter.new(before, after, {
|
18
|
+
# format: :html,
|
19
|
+
# include_metrics: true,
|
20
|
+
# highlight_breaking_changes: true
|
21
|
+
# })
|
22
|
+
#
|
23
|
+
class GitDiffFormatter
|
24
|
+
# Creates a new diff formatter
|
25
|
+
#
|
26
|
+
# @param before_graph [FlowGraph] The graph representing the "before" state
|
27
|
+
# @param after_graph [FlowGraph] The graph representing the "after" state
|
28
|
+
# @param options [Hash] Configuration options
|
29
|
+
# @option options [Symbol] :format Output format (:mermaid, :html, :text) (default: :mermaid)
|
30
|
+
# @option options [Boolean] :include_metrics Include complexity metrics (default: true)
|
31
|
+
# @option options [Boolean] :highlight_breaking_changes Highlight breaking changes (default: true)
|
32
|
+
def initialize(before_graph, after_graph, options = {})
|
33
|
+
@before_graph = before_graph
|
34
|
+
@after_graph = after_graph
|
35
|
+
@options = options
|
36
|
+
@format = options[:format] || :mermaid
|
37
|
+
end
|
38
|
+
|
39
|
+
# Generates the diff visualization
|
40
|
+
#
|
41
|
+
# @return [String] The formatted diff in the requested format
|
42
|
+
# @raise [ArgumentError] If an unknown format is specified
|
43
|
+
def format
|
44
|
+
diff_result = analyze_differences
|
45
|
+
|
46
|
+
case @format
|
47
|
+
when :mermaid
|
48
|
+
format_as_mermaid(diff_result)
|
49
|
+
when :html
|
50
|
+
format_as_html(diff_result)
|
51
|
+
else
|
52
|
+
format_as_text(diff_result)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def analyze_differences
|
59
|
+
diff = {
|
60
|
+
added_nodes: [],
|
61
|
+
removed_nodes: [],
|
62
|
+
modified_nodes: [],
|
63
|
+
added_edges: [],
|
64
|
+
removed_edges: [],
|
65
|
+
modified_edges: [],
|
66
|
+
metrics_change: calculate_metrics_change,
|
67
|
+
summary: {}
|
68
|
+
}
|
69
|
+
|
70
|
+
before_nodes = @before_graph.nodes
|
71
|
+
after_nodes = @after_graph.nodes
|
72
|
+
|
73
|
+
# ノードの追加を検出
|
74
|
+
after_nodes.each do |id, node|
|
75
|
+
unless before_nodes[id]
|
76
|
+
diff[:added_nodes] << node
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# ノードの削除と変更を検出
|
81
|
+
before_nodes.each do |id, node|
|
82
|
+
if after_nodes[id]
|
83
|
+
# ノードが存在する場合、変更をチェック
|
84
|
+
if node_changed?(node, after_nodes[id])
|
85
|
+
diff[:modified_nodes] << {
|
86
|
+
before: node,
|
87
|
+
after: after_nodes[id],
|
88
|
+
changes: detect_node_changes(node, after_nodes[id])
|
89
|
+
}
|
90
|
+
end
|
91
|
+
else
|
92
|
+
diff[:removed_nodes] << node
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# エッジの変更を検出
|
97
|
+
before_edges = @before_graph.edges
|
98
|
+
after_edges = @after_graph.edges
|
99
|
+
|
100
|
+
# エッジの追加
|
101
|
+
after_edges.each do |edge|
|
102
|
+
unless edge_exists?(edge, before_edges)
|
103
|
+
diff[:added_edges] << edge
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
# エッジの削除
|
108
|
+
before_edges.each do |edge|
|
109
|
+
unless edge_exists?(edge, after_edges)
|
110
|
+
diff[:removed_edges] << edge
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# サマリーの生成
|
115
|
+
diff[:summary] = generate_summary(diff)
|
116
|
+
|
117
|
+
diff
|
118
|
+
end
|
119
|
+
|
120
|
+
def node_changed?(before_node, after_node)
|
121
|
+
before_node.name != after_node.name ||
|
122
|
+
before_node.type != after_node.type ||
|
123
|
+
before_node.attributes != after_node.attributes
|
124
|
+
end
|
125
|
+
|
126
|
+
def detect_node_changes(before_node, after_node)
|
127
|
+
changes = []
|
128
|
+
|
129
|
+
if before_node.name != after_node.name
|
130
|
+
changes << { type: :name, before: before_node.name, after: after_node.name }
|
131
|
+
end
|
132
|
+
|
133
|
+
if before_node.attributes != after_node.attributes
|
134
|
+
# 関連の変更を検出
|
135
|
+
before_assoc = before_node.attributes[:associations] || []
|
136
|
+
after_assoc = after_node.attributes[:associations] || []
|
137
|
+
|
138
|
+
added_assoc = after_assoc - before_assoc
|
139
|
+
removed_assoc = before_assoc - after_assoc
|
140
|
+
|
141
|
+
if added_assoc.any?
|
142
|
+
changes << { type: :associations_added, items: added_assoc }
|
143
|
+
end
|
144
|
+
|
145
|
+
if removed_assoc.any?
|
146
|
+
changes << { type: :associations_removed, items: removed_assoc }
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
changes
|
151
|
+
end
|
152
|
+
|
153
|
+
def edge_exists?(target_edge, edge_list)
|
154
|
+
edge_list.any? do |edge|
|
155
|
+
edge.from == target_edge.from &&
|
156
|
+
edge.to == target_edge.to &&
|
157
|
+
edge.type == target_edge.type
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def calculate_metrics_change
|
162
|
+
{
|
163
|
+
nodes: {
|
164
|
+
before: @before_graph.node_count,
|
165
|
+
after: @after_graph.node_count,
|
166
|
+
change: @after_graph.node_count - @before_graph.node_count
|
167
|
+
},
|
168
|
+
edges: {
|
169
|
+
before: @before_graph.edge_count,
|
170
|
+
after: @after_graph.edge_count,
|
171
|
+
change: @after_graph.edge_count - @before_graph.edge_count
|
172
|
+
},
|
173
|
+
complexity: calculate_complexity_change
|
174
|
+
}
|
175
|
+
end
|
176
|
+
|
177
|
+
def calculate_complexity_change
|
178
|
+
before_complexity = calculate_graph_complexity(@before_graph)
|
179
|
+
after_complexity = calculate_graph_complexity(@after_graph)
|
180
|
+
|
181
|
+
{
|
182
|
+
before: before_complexity,
|
183
|
+
after: after_complexity,
|
184
|
+
change: after_complexity - before_complexity,
|
185
|
+
percentage: before_complexity > 0 ? ((after_complexity - before_complexity) * 100.0 / before_complexity).round(2) : 0
|
186
|
+
}
|
187
|
+
end
|
188
|
+
|
189
|
+
def calculate_graph_complexity(graph)
|
190
|
+
# 簡易的な複雑度計算(ノード数 + エッジ数 * 2)
|
191
|
+
graph.node_count + (graph.edge_count * 2)
|
192
|
+
end
|
193
|
+
|
194
|
+
def generate_summary(diff)
|
195
|
+
{
|
196
|
+
total_changes: diff[:added_nodes].count + diff[:removed_nodes].count +
|
197
|
+
diff[:modified_nodes].count + diff[:added_edges].count +
|
198
|
+
diff[:removed_edges].count,
|
199
|
+
breaking_changes: detect_breaking_changes(diff),
|
200
|
+
recommendations: generate_recommendations(diff)
|
201
|
+
}
|
202
|
+
end
|
203
|
+
|
204
|
+
def detect_breaking_changes(diff)
|
205
|
+
breaking = []
|
206
|
+
|
207
|
+
# 削除されたコントローラーやアクション
|
208
|
+
diff[:removed_nodes].each do |node|
|
209
|
+
if [:controller, :action, :route].include?(node.type)
|
210
|
+
breaking << "#{node.type.to_s.capitalize} '#{node.name}' was removed"
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
# 削除されたモデルの関連
|
215
|
+
diff[:removed_edges].each do |edge|
|
216
|
+
if edge.type == :belongs_to
|
217
|
+
breaking << "Association removed: #{edge.from} belongs_to #{edge.to}"
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
breaking
|
222
|
+
end
|
223
|
+
|
224
|
+
def generate_recommendations(diff)
|
225
|
+
recommendations = []
|
226
|
+
|
227
|
+
# 大量の追加があった場合
|
228
|
+
if diff[:added_nodes].count > 10
|
229
|
+
recommendations << "Consider breaking down the changes into smaller commits"
|
230
|
+
end
|
231
|
+
|
232
|
+
# 複雑度が大幅に増加した場合
|
233
|
+
complexity_change = diff[:metrics_change][:complexity][:percentage]
|
234
|
+
if complexity_change > 20
|
235
|
+
recommendations << "Complexity increased by #{complexity_change}%. Consider refactoring"
|
236
|
+
end
|
237
|
+
|
238
|
+
# 循環依存が追加された可能性
|
239
|
+
if diff[:added_edges].count > diff[:added_nodes].count * 2
|
240
|
+
recommendations << "Many new dependencies added. Check for circular dependencies"
|
241
|
+
end
|
242
|
+
|
243
|
+
recommendations
|
244
|
+
end
|
245
|
+
|
246
|
+
def format_as_mermaid(diff)
|
247
|
+
output = []
|
248
|
+
output << "```mermaid"
|
249
|
+
output << "graph TD"
|
250
|
+
output << " subgraph Legend"
|
251
|
+
output << " Added[Added - Green]:::added"
|
252
|
+
output << " Removed[Removed - Red]:::removed"
|
253
|
+
output << " Modified[Modified - Yellow]:::modified"
|
254
|
+
output << " end"
|
255
|
+
output << ""
|
256
|
+
|
257
|
+
# 全ノードを表示(状態付き)
|
258
|
+
all_nodes = {}
|
259
|
+
|
260
|
+
# 既存のノード
|
261
|
+
@after_graph.nodes.each do |id, node|
|
262
|
+
if diff[:added_nodes].any? { |n| n.id == id }
|
263
|
+
all_nodes[id] = { node: node, status: :added }
|
264
|
+
elsif diff[:modified_nodes].any? { |m| m[:after].id == id }
|
265
|
+
all_nodes[id] = { node: node, status: :modified }
|
266
|
+
else
|
267
|
+
all_nodes[id] = { node: node, status: :unchanged }
|
268
|
+
end
|
269
|
+
end
|
270
|
+
|
271
|
+
# 削除されたノード
|
272
|
+
diff[:removed_nodes].each do |node|
|
273
|
+
all_nodes[node.id] = { node: node, status: :removed }
|
274
|
+
end
|
275
|
+
|
276
|
+
# ノードの描画
|
277
|
+
all_nodes.each do |id, data|
|
278
|
+
node = data[:node]
|
279
|
+
status = data[:status]
|
280
|
+
|
281
|
+
node_text = case node.type
|
282
|
+
when :controller
|
283
|
+
"#{node.name}[[#{node.name}]]"
|
284
|
+
when :action
|
285
|
+
"#{node.name}(#{node.name})"
|
286
|
+
else
|
287
|
+
"#{node.name}[#{node.name}]"
|
288
|
+
end
|
289
|
+
|
290
|
+
class_suffix = status == :unchanged ? "" : ":::#{status}"
|
291
|
+
output << " #{node_text}#{class_suffix}"
|
292
|
+
end
|
293
|
+
|
294
|
+
output << ""
|
295
|
+
|
296
|
+
# エッジの描画
|
297
|
+
all_edges = []
|
298
|
+
|
299
|
+
# 既存のエッジ
|
300
|
+
@after_graph.edges.each do |edge|
|
301
|
+
if diff[:added_edges].any? { |e| edge_equal?(e, edge) }
|
302
|
+
all_edges << { edge: edge, status: :added }
|
303
|
+
else
|
304
|
+
all_edges << { edge: edge, status: :unchanged }
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
# 削除されたエッジ
|
309
|
+
diff[:removed_edges].each do |edge|
|
310
|
+
all_edges << { edge: edge, status: :removed }
|
311
|
+
end
|
312
|
+
|
313
|
+
# エッジの描画
|
314
|
+
all_edges.each do |data|
|
315
|
+
edge = data[:edge]
|
316
|
+
status = data[:status]
|
317
|
+
|
318
|
+
style = case status
|
319
|
+
when :added
|
320
|
+
"==>"
|
321
|
+
when :removed
|
322
|
+
"-.->"
|
323
|
+
else
|
324
|
+
"-->"
|
325
|
+
end
|
326
|
+
|
327
|
+
label = edge.label ? "|#{edge.label}|" : ""
|
328
|
+
class_suffix = status == :unchanged ? "" : ":::#{status}"
|
329
|
+
|
330
|
+
from_exists = all_nodes[edge.from]
|
331
|
+
to_exists = all_nodes[edge.to]
|
332
|
+
|
333
|
+
if from_exists && to_exists
|
334
|
+
output << " #{edge.from} #{style}#{label} #{edge.to}#{class_suffix}"
|
335
|
+
end
|
336
|
+
end
|
337
|
+
|
338
|
+
output << ""
|
339
|
+
output << " classDef added fill:#90EE90,stroke:#006400,stroke-width:3px;"
|
340
|
+
output << " classDef removed fill:#FFB6C1,stroke:#8B0000,stroke-width:3px,stroke-dasharray: 5 5;"
|
341
|
+
output << " classDef modified fill:#FFFFE0,stroke:#FFD700,stroke-width:3px;"
|
342
|
+
output << "```"
|
343
|
+
|
344
|
+
# 変更サマリー
|
345
|
+
output << ""
|
346
|
+
output << "## 変更サマリー"
|
347
|
+
output << ""
|
348
|
+
output << "### 📊 メトリクス変化"
|
349
|
+
output << "- ノード数: #{diff[:metrics_change][:nodes][:before]} → #{diff[:metrics_change][:nodes][:after]} (#{format_change(diff[:metrics_change][:nodes][:change])})"
|
350
|
+
output << "- エッジ数: #{diff[:metrics_change][:edges][:before]} → #{diff[:metrics_change][:edges][:after]} (#{format_change(diff[:metrics_change][:edges][:change])})"
|
351
|
+
output << "- 複雑度: #{diff[:metrics_change][:complexity][:before]} → #{diff[:metrics_change][:complexity][:after]} (#{format_change(diff[:metrics_change][:complexity][:change])} / #{diff[:metrics_change][:complexity][:percentage]}%)"
|
352
|
+
|
353
|
+
if diff[:summary][:breaking_changes].any?
|
354
|
+
output << ""
|
355
|
+
output << "### ⚠️ 破壊的変更"
|
356
|
+
diff[:summary][:breaking_changes].each do |change|
|
357
|
+
output << "- #{change}"
|
358
|
+
end
|
359
|
+
end
|
360
|
+
|
361
|
+
if diff[:summary][:recommendations].any?
|
362
|
+
output << ""
|
363
|
+
output << "### 💡 推奨事項"
|
364
|
+
diff[:summary][:recommendations].each do |rec|
|
365
|
+
output << "- #{rec}"
|
366
|
+
end
|
367
|
+
end
|
368
|
+
|
369
|
+
output.join("\n")
|
370
|
+
end
|
371
|
+
|
372
|
+
def format_as_html(diff)
|
373
|
+
# HTML形式での差分表示(D3.jsを使用)
|
374
|
+
html_content = <<~HTML
|
375
|
+
<!DOCTYPE html>
|
376
|
+
<html>
|
377
|
+
<head>
|
378
|
+
<title>Rails Flow Map - Git Diff Visualization</title>
|
379
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
380
|
+
<style>
|
381
|
+
body { font-family: Arial, sans-serif; margin: 20px; }
|
382
|
+
.summary { background: #f0f0f0; padding: 20px; border-radius: 5px; margin-bottom: 20px; }
|
383
|
+
.metrics { display: flex; gap: 20px; margin-bottom: 20px; }
|
384
|
+
.metric-card { background: white; padding: 15px; border-radius: 5px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
|
385
|
+
.added { color: #28a745; }
|
386
|
+
.removed { color: #dc3545; }
|
387
|
+
.modified { color: #ffc107; }
|
388
|
+
.breaking { background: #fff3cd; border: 1px solid #ffeaa7; padding: 10px; border-radius: 5px; margin: 10px 0; }
|
389
|
+
#diff-graph { width: 100%; height: 600px; border: 1px solid #ddd; }
|
390
|
+
</style>
|
391
|
+
</head>
|
392
|
+
<body>
|
393
|
+
<h1>Rails Flow Map - Architecture Diff</h1>
|
394
|
+
|
395
|
+
<div class="summary">
|
396
|
+
<h2>変更サマリー</h2>
|
397
|
+
<div class="metrics">
|
398
|
+
<div class="metric-card">
|
399
|
+
<h3>ノード</h3>
|
400
|
+
<p>#{diff[:metrics_change][:nodes][:before]} → #{diff[:metrics_change][:nodes][:after]}</p>
|
401
|
+
<p class="#{diff[:metrics_change][:nodes][:change] >= 0 ? 'added' : 'removed'}">
|
402
|
+
#{format_change(diff[:metrics_change][:nodes][:change])}
|
403
|
+
</p>
|
404
|
+
</div>
|
405
|
+
<div class="metric-card">
|
406
|
+
<h3>エッジ</h3>
|
407
|
+
<p>#{diff[:metrics_change][:edges][:before]} → #{diff[:metrics_change][:edges][:after]}</p>
|
408
|
+
<p class="#{diff[:metrics_change][:edges][:change] >= 0 ? 'added' : 'removed'}">
|
409
|
+
#{format_change(diff[:metrics_change][:edges][:change])}
|
410
|
+
</p>
|
411
|
+
</div>
|
412
|
+
<div class="metric-card">
|
413
|
+
<h3>複雑度</h3>
|
414
|
+
<p>#{diff[:metrics_change][:complexity][:before]} → #{diff[:metrics_change][:complexity][:after]}</p>
|
415
|
+
<p class="#{diff[:metrics_change][:complexity][:change] >= 0 ? 'added' : 'removed'}">
|
416
|
+
#{format_change(diff[:metrics_change][:complexity][:change])} (#{diff[:metrics_change][:complexity][:percentage]}%)
|
417
|
+
</p>
|
418
|
+
</div>
|
419
|
+
</div>
|
420
|
+
|
421
|
+
#{generate_breaking_changes_html(diff)}
|
422
|
+
</div>
|
423
|
+
|
424
|
+
<div id="diff-graph"></div>
|
425
|
+
|
426
|
+
<script>
|
427
|
+
const diffData = #{generate_diff_data_json(diff)};
|
428
|
+
// D3.js visualization code here
|
429
|
+
</script>
|
430
|
+
</body>
|
431
|
+
</html>
|
432
|
+
HTML
|
433
|
+
|
434
|
+
html_content
|
435
|
+
end
|
436
|
+
|
437
|
+
def format_as_text(diff)
|
438
|
+
output = []
|
439
|
+
output << "Rails Flow Map - Architecture Diff Report"
|
440
|
+
output << "=" * 50
|
441
|
+
output << ""
|
442
|
+
|
443
|
+
# 追加されたノード
|
444
|
+
if diff[:added_nodes].any?
|
445
|
+
output << "## Added Nodes (#{diff[:added_nodes].count})"
|
446
|
+
diff[:added_nodes].each do |node|
|
447
|
+
output << " + #{node.type}: #{node.name}"
|
448
|
+
end
|
449
|
+
output << ""
|
450
|
+
end
|
451
|
+
|
452
|
+
# 削除されたノード
|
453
|
+
if diff[:removed_nodes].any?
|
454
|
+
output << "## Removed Nodes (#{diff[:removed_nodes].count})"
|
455
|
+
diff[:removed_nodes].each do |node|
|
456
|
+
output << " - #{node.type}: #{node.name}"
|
457
|
+
end
|
458
|
+
output << ""
|
459
|
+
end
|
460
|
+
|
461
|
+
# 変更されたノード
|
462
|
+
if diff[:modified_nodes].any?
|
463
|
+
output << "## Modified Nodes (#{diff[:modified_nodes].count})"
|
464
|
+
diff[:modified_nodes].each do |mod|
|
465
|
+
output << " ~ #{mod[:before].type}: #{mod[:before].name}"
|
466
|
+
mod[:changes].each do |change|
|
467
|
+
output << " - #{format_change_description(change)}"
|
468
|
+
end
|
469
|
+
end
|
470
|
+
output << ""
|
471
|
+
end
|
472
|
+
|
473
|
+
# メトリクス
|
474
|
+
output << "## Metrics"
|
475
|
+
output << " Nodes: #{diff[:metrics_change][:nodes][:before]} → #{diff[:metrics_change][:nodes][:after]} (#{format_change(diff[:metrics_change][:nodes][:change])})"
|
476
|
+
output << " Edges: #{diff[:metrics_change][:edges][:before]} → #{diff[:metrics_change][:edges][:after]} (#{format_change(diff[:metrics_change][:edges][:change])})"
|
477
|
+
output << " Complexity: #{diff[:metrics_change][:complexity][:before]} → #{diff[:metrics_change][:complexity][:after]} (#{format_change(diff[:metrics_change][:complexity][:change])})"
|
478
|
+
|
479
|
+
output.join("\n")
|
480
|
+
end
|
481
|
+
|
482
|
+
def edge_equal?(edge1, edge2)
|
483
|
+
edge1.from == edge2.from && edge1.to == edge2.to && edge1.type == edge2.type
|
484
|
+
end
|
485
|
+
|
486
|
+
def format_change(num)
|
487
|
+
num >= 0 ? "+#{num}" : num.to_s
|
488
|
+
end
|
489
|
+
|
490
|
+
def format_change_description(change)
|
491
|
+
case change[:type]
|
492
|
+
when :name
|
493
|
+
"Name changed: #{change[:before]} → #{change[:after]}"
|
494
|
+
when :associations_added
|
495
|
+
"Associations added: #{change[:items].join(', ')}"
|
496
|
+
when :associations_removed
|
497
|
+
"Associations removed: #{change[:items].join(', ')}"
|
498
|
+
else
|
499
|
+
"Changed: #{change[:type]}"
|
500
|
+
end
|
501
|
+
end
|
502
|
+
|
503
|
+
def generate_breaking_changes_html(diff)
|
504
|
+
return "" unless diff[:summary][:breaking_changes].any?
|
505
|
+
|
506
|
+
html = '<div class="breaking">'
|
507
|
+
html += '<h3>⚠️ 破壊的変更</h3>'
|
508
|
+
html += '<ul>'
|
509
|
+
diff[:summary][:breaking_changes].each do |change|
|
510
|
+
html += "<li>#{CGI.escapeHTML(change)}</li>"
|
511
|
+
end
|
512
|
+
html += '</ul>'
|
513
|
+
html += '</div>'
|
514
|
+
|
515
|
+
html
|
516
|
+
end
|
517
|
+
|
518
|
+
def generate_diff_data_json(diff)
|
519
|
+
{
|
520
|
+
nodes: generate_diff_nodes(diff),
|
521
|
+
edges: generate_diff_edges(diff)
|
522
|
+
}.to_json
|
523
|
+
end
|
524
|
+
|
525
|
+
def generate_diff_nodes(diff)
|
526
|
+
nodes = []
|
527
|
+
|
528
|
+
# 全ノードを収集
|
529
|
+
@after_graph.nodes.each do |id, node|
|
530
|
+
status = if diff[:added_nodes].any? { |n| n.id == id }
|
531
|
+
'added'
|
532
|
+
elsif diff[:modified_nodes].any? { |m| m[:after].id == id }
|
533
|
+
'modified'
|
534
|
+
else
|
535
|
+
'unchanged'
|
536
|
+
end
|
537
|
+
|
538
|
+
nodes << {
|
539
|
+
id: id,
|
540
|
+
name: node.name,
|
541
|
+
type: node.type,
|
542
|
+
status: status
|
543
|
+
}
|
544
|
+
end
|
545
|
+
|
546
|
+
# 削除されたノード
|
547
|
+
diff[:removed_nodes].each do |node|
|
548
|
+
nodes << {
|
549
|
+
id: CGI.escapeHTML(node.id.to_s),
|
550
|
+
name: CGI.escapeHTML(node.name.to_s),
|
551
|
+
type: node.type.to_s,
|
552
|
+
status: 'removed'
|
553
|
+
}
|
554
|
+
end
|
555
|
+
|
556
|
+
nodes
|
557
|
+
end
|
558
|
+
|
559
|
+
def generate_diff_edges(diff)
|
560
|
+
edges = []
|
561
|
+
|
562
|
+
# 全エッジを収集
|
563
|
+
@after_graph.edges.each do |edge|
|
564
|
+
status = diff[:added_edges].any? { |e| edge_equal?(e, edge) } ? 'added' : 'unchanged'
|
565
|
+
|
566
|
+
edges << {
|
567
|
+
source: edge.from,
|
568
|
+
target: edge.to,
|
569
|
+
type: edge.type,
|
570
|
+
label: edge.label,
|
571
|
+
status: status
|
572
|
+
}
|
573
|
+
end
|
574
|
+
|
575
|
+
# 削除されたエッジ
|
576
|
+
diff[:removed_edges].each do |edge|
|
577
|
+
edges << {
|
578
|
+
source: edge.from,
|
579
|
+
target: edge.to,
|
580
|
+
type: edge.type,
|
581
|
+
label: edge.label,
|
582
|
+
status: 'removed'
|
583
|
+
}
|
584
|
+
end
|
585
|
+
|
586
|
+
edges
|
587
|
+
end
|
588
|
+
end
|
589
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
module RailsFlowMap
|
2
|
+
class GraphVizFormatter
|
3
|
+
def format(graph)
|
4
|
+
lines = ["digraph RailsFlowMap {"]
|
5
|
+
lines << " rankdir=TB;"
|
6
|
+
lines << " node [shape=box];"
|
7
|
+
lines << ""
|
8
|
+
|
9
|
+
# Group nodes by type
|
10
|
+
models = graph.nodes_by_type(:model)
|
11
|
+
controllers = graph.nodes_by_type(:controller)
|
12
|
+
actions = graph.nodes_by_type(:action)
|
13
|
+
|
14
|
+
# Add models subgraph
|
15
|
+
unless models.empty?
|
16
|
+
lines << " subgraph cluster_models {"
|
17
|
+
lines << " label=\"Models\";"
|
18
|
+
lines << " style=filled;"
|
19
|
+
lines << " color=lightgrey;"
|
20
|
+
lines << " node [style=filled,color=pink];"
|
21
|
+
models.each do |node|
|
22
|
+
lines << " #{node.id} [label=\"#{escape_label(node.name)}\"];"
|
23
|
+
end
|
24
|
+
lines << " }"
|
25
|
+
lines << ""
|
26
|
+
end
|
27
|
+
|
28
|
+
# Add controllers subgraph
|
29
|
+
unless controllers.empty?
|
30
|
+
lines << " subgraph cluster_controllers {"
|
31
|
+
lines << " label=\"Controllers\";"
|
32
|
+
lines << " style=filled;"
|
33
|
+
lines << " color=lightblue;"
|
34
|
+
lines << " node [style=filled,color=lightblue];"
|
35
|
+
controllers.each do |node|
|
36
|
+
lines << " #{node.id} [label=\"#{escape_label(node.name)}\",shape=component];"
|
37
|
+
end
|
38
|
+
lines << " }"
|
39
|
+
lines << ""
|
40
|
+
end
|
41
|
+
|
42
|
+
# Add actions subgraph
|
43
|
+
unless actions.empty?
|
44
|
+
lines << " subgraph cluster_actions {"
|
45
|
+
lines << " label=\"Actions\";"
|
46
|
+
lines << " style=filled;"
|
47
|
+
lines << " color=lightgreen;"
|
48
|
+
lines << " node [style=filled,color=lightgreen,shape=ellipse];"
|
49
|
+
actions.each do |node|
|
50
|
+
controller_name = node.attributes[:controller] || "Unknown"
|
51
|
+
lines << " #{node.id} [label=\"#{escape_label(controller_name)}##{escape_label(node.name)}\"];"
|
52
|
+
end
|
53
|
+
lines << " }"
|
54
|
+
lines << ""
|
55
|
+
end
|
56
|
+
|
57
|
+
# Add edges
|
58
|
+
graph.edges.each do |edge|
|
59
|
+
from_node = graph.find_node(edge.from)
|
60
|
+
to_node = graph.find_node(edge.to)
|
61
|
+
|
62
|
+
next unless from_node && to_node
|
63
|
+
|
64
|
+
edge_attrs = format_edge_attributes(edge)
|
65
|
+
lines << " #{edge.from} -> #{edge.to} [#{edge_attrs}];"
|
66
|
+
end
|
67
|
+
|
68
|
+
lines << "}"
|
69
|
+
|
70
|
+
lines.join("\n")
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
def escape_label(text)
|
76
|
+
text.to_s.gsub('"', '\\"')
|
77
|
+
end
|
78
|
+
|
79
|
+
def format_edge_attributes(edge)
|
80
|
+
attrs = []
|
81
|
+
|
82
|
+
# Add label if present
|
83
|
+
if edge.label
|
84
|
+
attrs << "label=\"#{escape_label(edge.label)}\""
|
85
|
+
end
|
86
|
+
|
87
|
+
# Add style based on edge type
|
88
|
+
case edge.type
|
89
|
+
when :belongs_to
|
90
|
+
attrs << "style=solid"
|
91
|
+
attrs << "arrowhead=normal"
|
92
|
+
when :has_one
|
93
|
+
attrs << "style=solid"
|
94
|
+
attrs << "arrowhead=normal"
|
95
|
+
when :has_many
|
96
|
+
attrs << "style=solid"
|
97
|
+
attrs << "arrowhead=crow"
|
98
|
+
when :has_and_belongs_to_many
|
99
|
+
attrs << "style=solid"
|
100
|
+
attrs << "arrowhead=crow"
|
101
|
+
attrs << "arrowtail=crow"
|
102
|
+
attrs << "dir=both"
|
103
|
+
when :has_action
|
104
|
+
attrs << "style=dashed"
|
105
|
+
attrs << "color=gray"
|
106
|
+
end
|
107
|
+
|
108
|
+
attrs.join(", ")
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|