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.
@@ -0,0 +1,288 @@
1
+ module RailsFlowMap
2
+ # Generates sequence diagrams showing request flow through the application
3
+ #
4
+ # This formatter creates Mermaid sequence diagrams that visualize how requests
5
+ # flow through controllers, services, models, and the database. It can optionally
6
+ # include middleware, callbacks, and validation steps.
7
+ #
8
+ # @example Basic usage
9
+ # formatter = SequenceFormatter.new(graph, endpoint: '/api/users')
10
+ # diagram = formatter.format
11
+ #
12
+ # @example With all features enabled
13
+ # formatter = SequenceFormatter.new(graph, {
14
+ # endpoint: '/api/users',
15
+ # include_middleware: true,
16
+ # include_callbacks: true,
17
+ # include_validations: true,
18
+ # include_database: true
19
+ # })
20
+ #
21
+ class SequenceFormatter
22
+ # Creates a new sequence diagram formatter
23
+ #
24
+ # @param graph [FlowGraph] The graph to analyze
25
+ # @param options [Hash] Configuration options
26
+ # @option options [String] :endpoint The endpoint to analyze (analyzes all if nil)
27
+ # @option options [Boolean] :include_middleware Include middleware in diagram (default: false)
28
+ # @option options [Boolean] :include_callbacks Include callbacks in diagram (default: false)
29
+ # @option options [Boolean] :include_validations Include validations in diagram (default: false)
30
+ # @option options [Boolean] :include_database Include database queries in diagram (default: true)
31
+ def initialize(graph, options = {})
32
+ @graph = graph
33
+ @endpoint = options[:endpoint]
34
+ @include_middleware = options[:include_middleware] || false
35
+ @include_callbacks = options[:include_callbacks] || false
36
+ @include_validations = options[:include_validations] || false
37
+ @include_database = options[:include_database] || true
38
+ end
39
+
40
+ # Generates the sequence diagram
41
+ #
42
+ # @param graph [FlowGraph] Optional graph to format (uses instance graph by default)
43
+ # @return [String] Mermaid sequence diagram markup
44
+ def format(graph = @graph)
45
+ output = []
46
+ output << "```mermaid"
47
+ output << "sequenceDiagram"
48
+ output << " autonumber"
49
+
50
+ # 参加者の定義
51
+ output.concat(define_participants(graph))
52
+
53
+ # エンドポイントに関連するノードを取得
54
+ route_nodes = filter_route_nodes(graph)
55
+
56
+ if route_nodes.empty?
57
+ output << " Note over Client: No routes found for endpoint: #{@endpoint}"
58
+ else
59
+ route_nodes.each do |route|
60
+ output << ""
61
+ output << " Note over Client: === #{route.attributes[:verb]} #{route.attributes[:path]} ==="
62
+ output.concat(generate_sequence_for_route(route, graph))
63
+ end
64
+ end
65
+
66
+ output << "```"
67
+ output.join("\n")
68
+ end
69
+
70
+ private
71
+
72
+ def define_participants(graph)
73
+ participants = [" participant Client", " participant Router"]
74
+
75
+ if @include_middleware
76
+ participants << " participant Middleware"
77
+ end
78
+
79
+ # コントローラーの追加
80
+ controllers = graph.nodes_by_type(:controller).map(&:name).uniq
81
+ controllers.each do |controller|
82
+ participants << " participant #{sanitize_name(controller)}"
83
+ end
84
+
85
+ # サービスの追加
86
+ services = graph.nodes_by_type(:service).map(&:name).uniq
87
+ services.each do |service|
88
+ participants << " participant #{sanitize_name(service)}"
89
+ end
90
+
91
+ # モデルの追加
92
+ models = graph.nodes_by_type(:model).map(&:name).uniq
93
+ models.each do |model|
94
+ participants << " participant #{sanitize_name(model)}"
95
+ end
96
+
97
+ if @include_database
98
+ participants << " participant Database"
99
+ end
100
+
101
+ participants
102
+ end
103
+
104
+ def filter_route_nodes(graph)
105
+ route_nodes = graph.nodes_by_type(:route)
106
+
107
+ if @endpoint
108
+ # エンドポイントの正確なマッチング
109
+ route_nodes = route_nodes.select do |node|
110
+ path = node.attributes[:path]
111
+ path == @endpoint || path&.gsub(/:[\w_]+/, '*') == @endpoint.gsub(/\d+/, '*')
112
+ end
113
+ end
114
+
115
+ route_nodes
116
+ end
117
+
118
+ def generate_sequence_for_route(route, graph)
119
+ lines = []
120
+
121
+ # ミドルウェア処理
122
+ if @include_middleware
123
+ lines << " Client->>Router: #{route.attributes[:verb]} #{route.attributes[:path]}"
124
+ lines << " Router->>Middleware: Process request"
125
+ lines << " activate Middleware"
126
+ lines << " Note right of Middleware: Authentication<br/>CORS<br/>Rate limiting"
127
+ lines << " Middleware->>Router: Continue"
128
+ lines << " deactivate Middleware"
129
+ else
130
+ lines << " Client->>+Router: #{route.attributes[:verb]} #{route.attributes[:path]}"
131
+ end
132
+
133
+ # ルートからアクションへの処理
134
+ route_edges = graph.edges.select { |e| e.from == route.id && e.type == :routes_to }
135
+
136
+ route_edges.each do |edge|
137
+ action = graph.find_node(edge.to)
138
+ next unless action
139
+
140
+ controller_name = sanitize_name(action.attributes[:controller] || "Controller")
141
+
142
+ # コントローラーアクションの開始
143
+ lines << " Router->>+#{controller_name}: #{action.name}(params)"
144
+
145
+ # コールバック処理
146
+ if @include_callbacks
147
+ lines << " activate #{controller_name}"
148
+ lines << " Note over #{controller_name}: before_action callbacks"
149
+ end
150
+
151
+ # バリデーション
152
+ if @include_validations && ['create', 'update'].include?(action.name)
153
+ lines << " #{controller_name}->>#{controller_name}: validate_params"
154
+ lines << " alt Invalid parameters"
155
+ lines << " #{controller_name}-->>Client: 422 Unprocessable Entity"
156
+ lines << " else Valid parameters"
157
+ end
158
+
159
+ # サービス呼び出し
160
+ lines.concat(generate_service_calls(action, controller_name, graph))
161
+
162
+ # バリデーションの終了
163
+ if @include_validations && ['create', 'update'].include?(action.name)
164
+ lines << " end"
165
+ end
166
+
167
+ # レスポンスの生成
168
+ lines << " #{controller_name}->>#{controller_name}: render_response"
169
+
170
+ # コールバック処理の終了
171
+ if @include_callbacks
172
+ lines << " Note over #{controller_name}: after_action callbacks"
173
+ lines << " deactivate #{controller_name}"
174
+ end
175
+
176
+ # クライアントへのレスポンス
177
+ lines << " #{controller_name}-->>-Router: Response data"
178
+ lines << " Router-->>-Client: #{generate_response_code(action.name)} {data}"
179
+ end
180
+
181
+ lines
182
+ end
183
+
184
+ def generate_service_calls(action, controller_name, graph)
185
+ lines = []
186
+
187
+ # アクションからサービスへの呼び出し
188
+ service_edges = graph.edges.select { |e| e.from == action.id && e.type == :calls_service }
189
+
190
+ service_edges.each_with_index do |service_edge, index|
191
+ service = graph.find_node(service_edge.to)
192
+ next unless service
193
+
194
+ service_name = sanitize_name(service.name)
195
+ method_name = service_edge.label || "process"
196
+
197
+ # サービスメソッドの呼び出し
198
+ lines << " #{controller_name}->>+#{service_name}: #{method_name}(params)"
199
+
200
+ # サービス内の処理
201
+ lines << " activate #{service_name}"
202
+ lines << " Note over #{service_name}: Business logic"
203
+
204
+ # モデルアクセス
205
+ lines.concat(generate_model_access(service, service_name, graph))
206
+
207
+ # サービスからのレスポンス
208
+ lines << " deactivate #{service_name}"
209
+ lines << " #{service_name}-->>-#{controller_name}: ServiceResult"
210
+
211
+ # エラーハンドリング
212
+ if index == 0 # 最初のサービス呼び出しのみ
213
+ lines << " alt Service error"
214
+ lines << " #{controller_name}-->>Client: 500 Internal Server Error"
215
+ lines << " else Success"
216
+ end
217
+ end
218
+
219
+ # エラーハンドリングの終了
220
+ if service_edges.any?
221
+ lines << " end"
222
+ end
223
+
224
+ lines
225
+ end
226
+
227
+ def generate_model_access(service, service_name, graph)
228
+ lines = []
229
+
230
+ # サービスからモデルへのアクセス
231
+ model_edges = graph.edges.select { |e| e.from == service.id && e.type == :accesses_model }
232
+
233
+ model_edges.each do |model_edge|
234
+ model = graph.find_node(model_edge.to)
235
+ next unless model
236
+
237
+ model_name = sanitize_name(model.name)
238
+ query = model_edge.label || "query"
239
+
240
+ # モデルへのクエリ
241
+ lines << " #{service_name}->>+#{model_name}: #{query}"
242
+
243
+ # データベースアクセス
244
+ if @include_database
245
+ lines << " #{model_name}->>+Database: SQL Query"
246
+ lines << " Note right of Database: SELECT * FROM #{model_name.downcase}s<br/>WHERE ..."
247
+ lines << " Database-->>-#{model_name}: ResultSet"
248
+ else
249
+ lines << " activate #{model_name}"
250
+ lines << " Note over #{model_name}: Database query"
251
+ lines << " deactivate #{model_name}"
252
+ end
253
+
254
+ # モデルからの結果
255
+ lines << " #{model_name}-->>-#{service_name}: [#{model_name} objects]"
256
+ end
257
+
258
+ # 関連モデルの読み込み
259
+ if model_edges.any? && @include_database
260
+ lines << " Note over #{service_name}: Load associations (N+1 prevention)"
261
+ end
262
+
263
+ lines
264
+ end
265
+
266
+ def generate_response_code(action_name)
267
+ case action_name
268
+ when 'index', 'show'
269
+ '200 OK'
270
+ when 'create'
271
+ '201 Created'
272
+ when 'update'
273
+ '200 OK'
274
+ when 'destroy'
275
+ '204 No Content'
276
+ else
277
+ '200 OK'
278
+ end
279
+ end
280
+
281
+ def sanitize_name(name)
282
+ # Mermaidで使用できない文字を置換
283
+ sanitized = name.to_s.gsub(/[^a-zA-Z0-9_]/, '_').gsub(/^_+|_+$/, '')
284
+ # 空文字列になった場合はデフォルト値を返す
285
+ sanitized.empty? ? "node_#{name.hash.abs}" : sanitized
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,34 @@
1
+ # RailsFlowMap configuration
2
+ #
3
+ # This initializer allows you to configure RailsFlowMap behavior
4
+ # You can customize the analysis and output options here
5
+
6
+ RailsFlowMap.configure do |config|
7
+ # Directories to exclude from analysis
8
+ # config.exclude_paths = ['app/models/concerns', 'app/controllers/concerns']
9
+
10
+ # Model files pattern (default: app/models/**/*.rb)
11
+ # config.model_pattern = 'app/models/**/*.rb'
12
+
13
+ # Controller files pattern (default: app/controllers/**/*.rb)
14
+ # config.controller_pattern = 'app/controllers/**/*.rb'
15
+
16
+ # Default output format (mermaid, plantuml, graphviz)
17
+ # config.default_format = :mermaid
18
+
19
+ # Default output directory
20
+ # config.output_directory = 'doc/flow_maps'
21
+
22
+ # Include STI (Single Table Inheritance) relationships
23
+ # config.include_sti = true
24
+
25
+ # Include polymorphic relationships
26
+ # config.include_polymorphic = true
27
+
28
+ # Custom node colors (for Mermaid format)
29
+ # config.node_colors = {
30
+ # model: '#f9f',
31
+ # controller: '#bbf',
32
+ # action: '#bfb'
33
+ # }
34
+ end
@@ -0,0 +1,32 @@
1
+ require 'rails/generators/base'
2
+
3
+ module RailsFlowMap
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path('templates', __dir__)
7
+
8
+ desc "Creates a RailsFlowMap initializer"
9
+
10
+ def create_initializer_file
11
+ template "rails_flow_map.rb", "config/initializers/rails_flow_map.rb"
12
+ end
13
+
14
+ def create_output_directory
15
+ empty_directory "doc/flow_maps"
16
+ create_file "doc/flow_maps/.gitkeep"
17
+ end
18
+
19
+ def display_post_install_message
20
+ say "\nRailsFlowMap has been successfully installed!", :green
21
+ say "\nAvailable rake tasks:"
22
+ say " rake rails_flow_map:generate # Generate complete flow map"
23
+ say " rake rails_flow_map:models # Generate model relationships"
24
+ say " rake rails_flow_map:controllers # Generate controller/action flow"
25
+ say " rake rails_flow_map:formats # List available formats"
26
+ say "\nExample usage:"
27
+ say " rake rails_flow_map:generate[mermaid,doc/flow_maps/app_flow.md]"
28
+ say "\nFlow maps will be saved in doc/flow_maps/ by default."
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module RailsFlowMap
6
+ # Centralized logging functionality for RailsFlowMap
7
+ #
8
+ # This module provides a standardized logging interface across all components
9
+ # of the RailsFlowMap gem. It supports configurable log levels, structured
10
+ # logging, and automatic context inclusion.
11
+ #
12
+ # @example Basic usage
13
+ # RailsFlowMap::Logging.logger.info("Processing graph with 10 nodes")
14
+ #
15
+ # @example With context
16
+ # RailsFlowMap::Logging.with_context(formatter: 'MermaidFormatter') do
17
+ # RailsFlowMap::Logging.logger.debug("Starting format operation")
18
+ # end
19
+ #
20
+ # @example Error logging with context
21
+ # begin
22
+ # risky_operation
23
+ # rescue => e
24
+ # RailsFlowMap::Logging.log_error(e, context: { operation: 'export' })
25
+ # end
26
+ module Logging
27
+ extend self
28
+
29
+ # Custom formatter for structured log output
30
+ class StructuredFormatter < Logger::Formatter
31
+ def call(severity, time, progname, msg)
32
+ context = Thread.current[:rails_flow_map_context] || {}
33
+ context_str = context.empty? ? '' : " [#{context.map { |k, v| "#{k}=#{v}" }.join(', ')}]"
34
+
35
+ "[#{time.strftime('%Y-%m-%d %H:%M:%S')}] #{severity}#{context_str}: #{msg}\n"
36
+ end
37
+ end
38
+
39
+ # Custom log levels for domain-specific logging
40
+ CUSTOM_LEVELS = {
41
+ security: Logger::WARN,
42
+ performance: Logger::INFO,
43
+ graph_analysis: Logger::DEBUG
44
+ }.freeze
45
+
46
+ # @return [Logger] The shared logger instance
47
+ def logger
48
+ @logger ||= create_logger
49
+ end
50
+
51
+ # Sets a custom logger instance
52
+ #
53
+ # @param custom_logger [Logger] The logger to use
54
+ def logger=(custom_logger)
55
+ @logger = custom_logger
56
+ end
57
+
58
+ # Log an error with full context and backtrace
59
+ #
60
+ # @param error [Exception] The error to log
61
+ # @param context [Hash] Additional context information
62
+ # @param level [Symbol] Log level (:error, :warn, :info, :debug)
63
+ def log_error(error, context: {}, level: :error)
64
+ error_context = {
65
+ error_class: error.class.name,
66
+ error_message: error.message,
67
+ **context
68
+ }
69
+
70
+ with_context(error_context) do
71
+ logger.send(level, "#{error.class}: #{error.message}")
72
+
73
+ if error.backtrace && logger.level <= Logger::DEBUG
74
+ logger.debug("Backtrace:\n#{error.backtrace.join("\n")}")
75
+ end
76
+ end
77
+ end
78
+
79
+ # Execute a block with additional logging context
80
+ #
81
+ # @param context [Hash] Context to add to all log messages in the block
82
+ # @yield The block to execute with context
83
+ def with_context(context = {})
84
+ old_context = Thread.current[:rails_flow_map_context] || {}
85
+ Thread.current[:rails_flow_map_context] = old_context.merge(context)
86
+
87
+ yield
88
+ ensure
89
+ Thread.current[:rails_flow_map_context] = old_context
90
+ end
91
+
92
+ # Log a performance metric
93
+ #
94
+ # @param operation [String] Name of the operation
95
+ # @param duration [Float] Duration in seconds
96
+ # @param additional_metrics [Hash] Additional metrics to log
97
+ def log_performance(operation, duration, additional_metrics = {})
98
+ return unless logger.level <= CUSTOM_LEVELS[:performance]
99
+
100
+ metrics = {
101
+ operation: operation,
102
+ duration_seconds: duration.round(3),
103
+ **additional_metrics
104
+ }
105
+
106
+ with_context(metrics) do
107
+ logger.info("Performance: #{operation} completed in #{duration.round(3)}s")
108
+ end
109
+ end
110
+
111
+ # Log a security event
112
+ #
113
+ # @param event [String] Description of the security event
114
+ # @param severity [Symbol] Severity level (:high, :medium, :low)
115
+ # @param details [Hash] Additional security-related details
116
+ def log_security(event, severity: :medium, details: {})
117
+ return unless logger.level <= CUSTOM_LEVELS[:security]
118
+
119
+ security_context = {
120
+ security_event: event,
121
+ severity: severity,
122
+ **details
123
+ }
124
+
125
+ with_context(security_context) do
126
+ logger.warn("SECURITY: #{event}")
127
+ end
128
+ end
129
+
130
+ # Log graph analysis information
131
+ #
132
+ # @param analysis_type [String] Type of analysis being performed
133
+ # @param graph_metrics [Hash] Metrics about the graph being analyzed
134
+ def log_graph_analysis(analysis_type, graph_metrics = {})
135
+ return unless logger.level <= CUSTOM_LEVELS[:graph_analysis]
136
+
137
+ analysis_context = {
138
+ analysis_type: analysis_type,
139
+ **graph_metrics
140
+ }
141
+
142
+ with_context(analysis_context) do
143
+ logger.debug("Graph Analysis: #{analysis_type}")
144
+
145
+ if graph_metrics.any?
146
+ metrics_str = graph_metrics.map { |k, v| "#{k}=#{v}" }.join(', ')
147
+ logger.debug("Graph metrics: #{metrics_str}")
148
+ end
149
+ end
150
+ end
151
+
152
+ # Time a block and log its performance
153
+ #
154
+ # @param operation [String] Name of the operation being timed
155
+ # @param additional_metrics [Hash] Additional metrics to include
156
+ # @yield The block to time
157
+ # @return The result of the block
158
+ def time_operation(operation, additional_metrics = {})
159
+ start_time = Time.now
160
+
161
+ begin
162
+ result = yield
163
+ duration = Time.now - start_time
164
+ log_performance(operation, duration, additional_metrics)
165
+ result
166
+ rescue => e
167
+ duration = Time.now - start_time
168
+ log_error(e, context: {
169
+ operation: operation,
170
+ duration_seconds: duration.round(3),
171
+ **additional_metrics
172
+ })
173
+ raise
174
+ end
175
+ end
176
+
177
+ # Configure logging for different environments
178
+ #
179
+ # @param environment [Symbol] The environment (:development, :test, :production)
180
+ # @param options [Hash] Additional configuration options
181
+ def configure_for_environment(environment, options = {})
182
+ case environment
183
+ when :development
184
+ logger.level = Logger::DEBUG
185
+ logger.formatter = StructuredFormatter.new
186
+ when :test
187
+ logger.level = Logger::WARN
188
+ logger.formatter = Logger::Formatter.new
189
+ when :production
190
+ logger.level = Logger::INFO
191
+ logger.formatter = StructuredFormatter.new
192
+ end
193
+
194
+ # Apply any custom options
195
+ options.each do |key, value|
196
+ case key
197
+ when :level
198
+ logger.level = value
199
+ when :formatter
200
+ logger.formatter = value
201
+ end
202
+ end
203
+ end
204
+
205
+ private
206
+
207
+ def create_logger
208
+ logger = Logger.new(STDOUT)
209
+ logger.level = Logger::INFO
210
+ logger.formatter = StructuredFormatter.new
211
+ logger.progname = 'RailsFlowMap'
212
+ logger
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,31 @@
1
+ module RailsFlowMap
2
+ class FlowEdge
3
+ attr_accessor :from, :to, :type, :label, :attributes
4
+
5
+ def initialize(from:, to:, type:, label: nil, attributes: {})
6
+ @from = from
7
+ @to = to
8
+ @type = type
9
+ @label = label
10
+ @attributes = attributes
11
+ end
12
+
13
+ def association?
14
+ [:belongs_to, :has_one, :has_many, :has_and_belongs_to_many].include?(type)
15
+ end
16
+
17
+ def action_flow?
18
+ type == :action_flow
19
+ end
20
+
21
+ def to_h
22
+ {
23
+ from: from,
24
+ to: to,
25
+ type: type,
26
+ label: label,
27
+ attributes: attributes
28
+ }
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,58 @@
1
+ module RailsFlowMap
2
+ class FlowGraph
3
+ attr_reader :nodes, :edges
4
+
5
+ def initialize
6
+ @nodes = {}
7
+ @edges = []
8
+ end
9
+
10
+ def add_node(node)
11
+ @nodes[node.id] = node
12
+ end
13
+
14
+ def add_edge(edge)
15
+ @edges << edge
16
+ end
17
+
18
+ def find_node(id)
19
+ @nodes[id]
20
+ end
21
+
22
+ def nodes_by_type(type)
23
+ @nodes.values.select { |node| node.type == type }
24
+ end
25
+
26
+ def edges_by_type(type)
27
+ @edges.select { |edge| edge.type == type }
28
+ end
29
+
30
+ def connected_nodes(node_id, direction: :both)
31
+ case direction
32
+ when :outgoing
33
+ @edges.select { |e| e.from == node_id }.map { |e| @nodes[e.to] }.compact
34
+ when :incoming
35
+ @edges.select { |e| e.to == node_id }.map { |e| @nodes[e.from] }.compact
36
+ when :both
37
+ outgoing = @edges.select { |e| e.from == node_id }.map { |e| @nodes[e.to] }
38
+ incoming = @edges.select { |e| e.to == node_id }.map { |e| @nodes[e.from] }
39
+ (outgoing + incoming).compact.uniq
40
+ end
41
+ end
42
+
43
+ def node_count
44
+ @nodes.size
45
+ end
46
+
47
+ def edge_count
48
+ @edges.size
49
+ end
50
+
51
+ def to_h
52
+ {
53
+ nodes: @nodes.transform_values(&:to_h),
54
+ edges: @edges.map(&:to_h)
55
+ }
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,37 @@
1
+ module RailsFlowMap
2
+ class FlowNode
3
+ attr_accessor :id, :name, :type, :attributes, :file_path, :line_number
4
+
5
+ def initialize(id:, name:, type:, attributes: {}, file_path: nil, line_number: nil)
6
+ @id = id
7
+ @name = name
8
+ @type = type
9
+ @attributes = attributes
10
+ @file_path = file_path
11
+ @line_number = line_number
12
+ end
13
+
14
+ def model?
15
+ type == :model
16
+ end
17
+
18
+ def controller?
19
+ type == :controller
20
+ end
21
+
22
+ def action?
23
+ type == :action
24
+ end
25
+
26
+ def to_h
27
+ {
28
+ id: id,
29
+ name: name,
30
+ type: type,
31
+ attributes: attributes,
32
+ file_path: file_path,
33
+ line_number: line_number
34
+ }
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,3 @@
1
+ module RailsFlowMap
2
+ VERSION = "0.1.0"
3
+ end