rails_visualizer 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,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsVisualizer
4
+ class Configuration
5
+ attr_accessor :excluded_models, :excluded_controllers, :excluded_jobs, :excluded_mailers,
6
+ :excluded_route_paths, :output_path, :open_browser, :theme, :parallel, :filename
7
+
8
+ def initialize
9
+ @excluded_models = []
10
+ @excluded_controllers = []
11
+ @excluded_jobs = []
12
+ @excluded_mailers = []
13
+ @excluded_route_paths = []
14
+ @output_path = 'tmp/rails_visualizer'
15
+ @open_browser = true
16
+ @theme = :light
17
+ @parallel = true
18
+ @filename = 'index.html'
19
+ end
20
+ end
21
+
22
+ class << self
23
+ def configuration
24
+ @configuration ||= Configuration.new
25
+ end
26
+
27
+ def configure
28
+ yield(configuration)
29
+ end
30
+
31
+ def reset_configuration!
32
+ @configuration = Configuration.new
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsVisualizer
4
+ class ControllersInspector
5
+ include RailsVisualizer::PathHelper
6
+
7
+ FRAMEWORK_NAMESPACES = %w[
8
+ ActionController::
9
+ ActionDispatch::
10
+ ActiveStorage::
11
+ ActionMailbox::
12
+ ActionText::
13
+ Rails::
14
+ Turbo::
15
+ Devise::
16
+ ].freeze
17
+
18
+ ACTION_FILTER_CLASS = 'AbstractController::Callbacks::ActionFilter'
19
+ ROUTABLE_ACTION_PATTERN = /\A[a-z_][a-zA-Z0-9_]*!?\z/
20
+
21
+ def initialize(excluded: [])
22
+ @excluded_set = excluded.to_set
23
+ @callbacks_by_class = {}
24
+ @callback_origin_cache = {}
25
+ end
26
+
27
+ def call
28
+ return [] unless defined?(ActionController::Base)
29
+
30
+ base = ActionController::Base.descendants
31
+ api = defined?(ActionController::API) ? ActionController::API.descendants : []
32
+
33
+ (base + api)
34
+ .uniq
35
+ .reject { |c| c.name.nil? || framework_class?(c.name) || @excluded_set.include?(c.name) }
36
+ .map { |c| serialize_controller(c) }
37
+ .sort_by { |c| c[:name] }
38
+ rescue StandardError
39
+ []
40
+ end
41
+
42
+ private
43
+
44
+ def serialize_controller(controller)
45
+ chain = callbacks_for(controller)
46
+ sc_chain = callbacks_for(controller.superclass)
47
+
48
+ # Partition the chain once — avoids iterating it 4+ times.
49
+ by_kind, cb_names = partition_chain(chain)
50
+ sc_by_kind = partition_chain(sc_chain).first
51
+
52
+ {
53
+ name: controller.name,
54
+ superclass: controller.superclass.name,
55
+ namespace: extract_namespace(controller.name),
56
+ file_path: source_path_for(controller),
57
+ controller_path: controller.controller_path,
58
+ actions: user_actions(controller),
59
+ public_helpers: public_helpers(controller, cb_names),
60
+ before_actions: build_kind(controller, :before, by_kind, sc_by_kind),
61
+ after_actions: build_kind(controller, :after, by_kind, sc_by_kind),
62
+ around_actions: build_kind(controller, :around, by_kind, sc_by_kind)
63
+ }
64
+ end
65
+
66
+ # Cached globally by class — many sibling controllers share the same superclass.
67
+ def callbacks_for(klass)
68
+ @callbacks_by_class[klass] ||= klass.send(:_process_action_callbacks).to_a
69
+ rescue StandardError
70
+ @callbacks_by_class[klass] = []
71
+ end
72
+
73
+ # Single pass: partitions symbol callbacks into { kind => [cb, ...] }
74
+ # and collects all callback names into a Set (for helper exclusion).
75
+ def partition_chain(chain)
76
+ by_kind = Hash.new { |h, k| h[k] = [] }
77
+ cb_names = Set.new
78
+ chain.each do |cb|
79
+ next unless cb.filter.is_a?(Symbol)
80
+
81
+ cb_names.add(cb.filter.to_s)
82
+ by_kind[cb.kind] << cb
83
+ end
84
+ [by_kind, cb_names]
85
+ end
86
+
87
+ def build_kind(controller, kind, by_kind, sc_by_kind)
88
+ sc_names = sc_by_kind[kind].to_set { |cb| cb.filter.to_s }
89
+
90
+ raw = by_kind[kind].filter_map do |cb|
91
+ name = cb.filter.to_s
92
+ inherited = sc_names.include?(name) ? callback_origin(controller, name, kind) : nil
93
+ serialize_callback(cb, inherited)
94
+ end
95
+ raw.uniq { |c| c[:name] }
96
+ rescue StandardError
97
+ []
98
+ end
99
+
100
+ # Methods physically written in the controller's own source file.
101
+ # Checks both owner (excludes plain module includes) and source_location
102
+ # (excludes methods injected via concern `included do` blocks).
103
+ def own_public_methods(controller)
104
+ ctrl_file = controller_source_file(controller)
105
+ controller.public_instance_methods(false).filter_map do |m|
106
+ um = controller.instance_method(m)
107
+ next unless um.owner == controller
108
+ next if ctrl_file && um.source_location&.first != ctrl_file
109
+
110
+ m.to_s
111
+ end
112
+ rescue StandardError
113
+ []
114
+ end
115
+
116
+ def controller_source_file(controller)
117
+ Module.const_source_location(controller.name)&.first
118
+ rescue StandardError
119
+ nil
120
+ end
121
+
122
+ def user_actions(controller)
123
+ own_public_methods(controller)
124
+ .grep(ROUTABLE_ACTION_PATTERN)
125
+ .sort
126
+ .map { |name| { name: name, defined_in: nil } }
127
+ end
128
+
129
+ def public_helpers(controller, cb_names)
130
+ own_public_methods(controller)
131
+ .reject { |m| ROUTABLE_ACTION_PATTERN.match?(m) || cb_names.include?(m) }
132
+ .sort
133
+ .map { |m| { name: m, defined_in: nil } }
134
+ end
135
+
136
+ def extract_namespace(name)
137
+ parts = name.split('::')
138
+ parts.size > 1 ? parts[0..-2].join('::') : nil
139
+ end
140
+
141
+ def callback_origin(controller, filter_name, kind)
142
+ key = [controller.superclass, filter_name, kind]
143
+ @callback_origin_cache.fetch(key) do
144
+ @callback_origin_cache[key] = compute_callback_origin(controller, filter_name, kind)
145
+ end
146
+ end
147
+
148
+ def compute_callback_origin(controller, filter_name, kind)
149
+ origin = nil
150
+ klass = controller.superclass
151
+ while user_controller_ancestor?(klass)
152
+ cbs = callbacks_for(klass)
153
+ origin = klass.name if cbs.any? { |cb| cb.kind == kind && cb.filter.to_s == filter_name }
154
+ klass = klass.superclass
155
+ end
156
+ origin
157
+ rescue StandardError
158
+ nil
159
+ end
160
+
161
+ # True for any user-defined ancestor that can hold action callbacks.
162
+ # Handles both ActionController::Base and ActionController::API hierarchies.
163
+ def user_controller_ancestor?(klass)
164
+ return false unless klass.is_a?(Class)
165
+
166
+ (klass < ActionController::Base) ||
167
+ (defined?(ActionController::API) && klass < ActionController::API)
168
+ end
169
+
170
+ def serialize_callback(callback, inherited_from = nil)
171
+ if_items = Array(callback.instance_variable_get(:@if))
172
+ unless_items = Array(callback.instance_variable_get(:@unless))
173
+
174
+ {
175
+ name: callback.filter.to_s,
176
+ only: action_filter_actions(if_items),
177
+ except: action_filter_actions(unless_items),
178
+ if_conditions: if_items.grep(Symbol).map(&:to_s),
179
+ unless_conditions: unless_items.grep(Symbol).map(&:to_s),
180
+ has_custom_condition: (if_items + unless_items).any?(Proc),
181
+ inherited_from: inherited_from
182
+ }
183
+ end
184
+
185
+ def action_filter_actions(items)
186
+ items.each_with_object([]) do |item, result|
187
+ next unless item.class.name == ACTION_FILTER_CLASS # rubocop:disable Style/ClassEqualityComparison
188
+
189
+ raw = item.instance_variable_get(:@actions)
190
+ result.concat(Array(raw).map(&:to_s).sort)
191
+ end
192
+ rescue StandardError
193
+ []
194
+ end
195
+
196
+ def framework_class?(name)
197
+ FRAMEWORK_NAMESPACES.any? { |ns| name.start_with?(ns) }
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsVisualizer
4
+ class GemsInspector
5
+ # Groups Bundler adds implicitly when no :group is specified — not user-defined.
6
+ IMPLICIT_GROUPS = %w[default].freeze
7
+
8
+ def call
9
+ return [] unless defined?(Bundler)
10
+
11
+ specs_map = build_specs_map
12
+ Bundler.definition.dependencies
13
+ .map { |dep| serialize_gem(dep, specs_map[dep.name]) }
14
+ .sort_by { |g| g[:name] }
15
+ rescue StandardError
16
+ []
17
+ end
18
+
19
+ private
20
+
21
+ def build_specs_map
22
+ Bundler.load.specs.each_with_object({}) do |spec, map|
23
+ map[spec.name] ||= spec
24
+ end
25
+ rescue StandardError
26
+ {}
27
+ end
28
+
29
+ def serialize_gem(dep, spec)
30
+ {
31
+ name: dep.name,
32
+ version: spec&.version.to_s.presence,
33
+ requirement: dep.requirement.to_s,
34
+ groups: normalize_groups(dep.groups),
35
+ source_tags: source_tags(dep, spec) + require_tags(dep),
36
+ summary: clean_summary(spec&.summary),
37
+ homepage: spec&.homepage.to_s.presence
38
+ }
39
+ end
40
+
41
+ # Returns explicit environment groups only. Gems with no explicit group
42
+ # (Bundler's implicit :default) return [] — the UI treats them as "Common".
43
+ def normalize_groups(groups)
44
+ groups.map(&:to_s).reject { |g| IMPLICIT_GROUPS.include?(g) }
45
+ end
46
+
47
+ def clean_summary(summary)
48
+ str = summary.to_s.strip
49
+ str.empty? ? nil : str
50
+ end
51
+
52
+ # Builds an array of human-readable parameter chips for non-rubygems sources,
53
+ # e.g. ["github: procore-oss/migration-lock-timeout", "branch: main", "path: engines/erp"].
54
+ def source_tags(dep, spec)
55
+ src = dep.source || spec&.source
56
+ return [] unless src
57
+
58
+ if src.is_a?(Bundler::Source::Git)
59
+ git_source_tags(src)
60
+ elsif src.is_a?(Bundler::Source::Path)
61
+ ["path: #{src.path}"]
62
+ else
63
+ []
64
+ end
65
+ rescue StandardError
66
+ []
67
+ end
68
+
69
+ def git_source_tags(src)
70
+ tags = [git_origin_tag(src.uri.to_s)]
71
+ tags << "branch: #{src.branch}" if src.branch
72
+ tags << "tag: #{src.tag}" if src.tag
73
+ # Only surface a bare ref if no branch/tag was given (pinned SHA)
74
+ tags << "ref: #{src.ref[0..7]}" if src.ref && !src.branch && !src.tag
75
+ tags.compact
76
+ end
77
+
78
+ # Builds chips for the require: option.
79
+ # require: false → ["require: false"]
80
+ # require: 'exifr/jpeg' → ["require: exifr/jpeg"]
81
+ # require: [a, b] → ["require: a, b"]
82
+ # (not specified) → []
83
+ def require_tags(dep)
84
+ ar = dep.autorequire
85
+ return [] if ar.nil? # no require: key at all → default behaviour
86
+ return ['require: false'] if ar.empty?
87
+
88
+ paths = Array(ar).map(&:to_s).reject(&:empty?)
89
+ paths.empty? ? [] : ["require: #{paths.join(', ')}"]
90
+ rescue StandardError
91
+ []
92
+ end
93
+
94
+ # Converts a git URI to a readable "github: owner/repo" or "git: <uri>" tag.
95
+ def git_origin_tag(uri)
96
+ github = uri.match(%r{github\.com[/:](.+?)(?:\.git)?$})
97
+ return "github: #{github[1]}" if github
98
+
99
+ gitlab = uri.match(%r{gitlab\.com[/:](.+?)(?:\.git)?$})
100
+ return "gitlab: #{gitlab[1]}" if gitlab
101
+
102
+ "git: #{uri}"
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsVisualizer
4
+ class Introspector
5
+ include RailsVisualizer::PathHelper
6
+
7
+ def initialize(configuration = RailsVisualizer.configuration)
8
+ @configuration = configuration
9
+ end
10
+
11
+ def call
12
+ eager_load!
13
+ futures = launch_parallel_inspectors
14
+ cache, ar_models, migrations = run_db_phase
15
+ thread_results = join_futures(futures)
16
+ models = run_model_inspection(cache, ar_models)
17
+
18
+ assemble(models, migrations, thread_results)
19
+ end
20
+
21
+ private
22
+
23
+ def launch_parallel_inspectors
24
+ config = @configuration
25
+ {
26
+ routes: Thread.new { RoutesInspector.new(excluded_paths: config.excluded_route_paths).call },
27
+ jobs: Thread.new { JobsInspector.new(excluded: config.excluded_jobs).call },
28
+ controllers: Thread.new { ControllersInspector.new(excluded: config.excluded_controllers).call },
29
+ mailers: Thread.new { MailersInspector.new(excluded: config.excluded_mailers).call },
30
+ gems: Thread.new { GemsInspector.new.call }
31
+ }
32
+ end
33
+
34
+ def run_db_phase
35
+ cache = Schema::Cache.new
36
+ ar_models = collect_models
37
+ table_names = ar_models.filter_map { |m| safe_table_name(m) }.uniq
38
+ cache.preload_for(table_names)
39
+ migrations = MigrationsInspector.new.call
40
+ [cache, ar_models, migrations]
41
+ end
42
+
43
+ def join_futures(futures)
44
+ futures.transform_values(&:value)
45
+ end
46
+
47
+ def run_model_inspection(cache, ar_models)
48
+ file_cache = {}
49
+ preload_source_files(ar_models, file_cache)
50
+ inspect_models_parallel(cache, ar_models, file_cache)
51
+ end
52
+
53
+ def assemble(models, migrations, thread_results)
54
+ result = { app_name: app_name, models: models, migrations: migrations }
55
+ thread_results.each { |k, v| result[k] = v }
56
+ result
57
+ end
58
+
59
+ # ------------------------------------------------------------------
60
+ # Source file + primary_key warmup (runs before fork)
61
+ # ------------------------------------------------------------------
62
+
63
+ def preload_source_files(ar_models, file_cache)
64
+ root = app_root
65
+ ar_models.each do |model|
66
+ path = source_path_for(model)
67
+ next unless path
68
+ next if file_cache.key?(path)
69
+
70
+ absolute = File.join(root, path)
71
+ file_cache[path] = File.exist?(absolute) ? File.read(absolute) : nil
72
+ rescue StandardError
73
+ file_cache[path] = nil if defined?(path) && path
74
+ end
75
+ end
76
+
77
+ # ------------------------------------------------------------------
78
+ # Fork-based parallel model inspection
79
+ # ------------------------------------------------------------------
80
+
81
+ def inspect_models_parallel(cache, ar_models, file_cache)
82
+ nworkers = determine_worker_count(ar_models.size)
83
+ return inspect_sequential(cache, ar_models, file_cache) if nworkers <= 1
84
+
85
+ fork_and_collect(cache, ar_models, file_cache, nworkers)
86
+ rescue NotImplementedError
87
+ inspect_sequential(cache, ar_models, file_cache)
88
+ end
89
+
90
+ def inspect_sequential(cache, ar_models, file_cache)
91
+ method_cache = {}
92
+ ar_models.map { |m| Schema::ModelInspector.new(m, cache, file_cache, method_cache).call }
93
+ end
94
+
95
+ def fork_and_collect(cache, ar_models, file_cache, nworkers)
96
+ slices = ar_models.each_slice((ar_models.size.to_f / nworkers).ceil).to_a
97
+ workers = slices.map.with_index do |slice, idx|
98
+ tmppath = File.join(Dir.tmpdir, "rails_visualizer_#{Process.pid}_#{idx}.dat")
99
+ pid = fork_worker(cache, slice, file_cache, tmppath)
100
+ [pid, tmppath]
101
+ end
102
+ collect_results(workers)
103
+ end
104
+
105
+ def fork_worker(cache, models, file_cache, tmppath)
106
+ Process.fork do
107
+ ActiveRecord::Base.connection_handler.clear_all_connections! rescue nil # rubocop:disable Style/RescueModifier
108
+ method_cache = {}
109
+ results = models.map do |m|
110
+ Schema::ModelInspector.new(m, cache, file_cache, method_cache).call
111
+ end
112
+ File.binwrite(tmppath, Marshal.dump(results))
113
+ rescue StandardError => e
114
+ warn "[RailsVisualizer] Fork worker error: #{e.message}"
115
+ File.binwrite(tmppath, Marshal.dump([]))
116
+ ensure
117
+ exit!(0) # rubocop:disable Rails/Exit
118
+ end
119
+ end
120
+
121
+ def collect_results(workers)
122
+ workers.flat_map do |pid, tmppath|
123
+ _, status = Process.waitpid2(pid)
124
+ next [] unless status.success? && File.exist?(tmppath)
125
+
126
+ Marshal.load(File.binread(tmppath)) # rubocop:disable Security/MarshalLoad
127
+ ensure
128
+ File.delete(tmppath) if tmppath && File.exist?(tmppath)
129
+ end
130
+ end
131
+
132
+ def determine_worker_count(model_count)
133
+ return 1 unless @configuration.parallel
134
+ return 1 unless Process.respond_to?(:fork)
135
+ return 1 if model_count < 50
136
+
137
+ require 'etc'
138
+ [Etc.nprocessors, 8, (model_count / 50.0).ceil].min
139
+ rescue StandardError
140
+ 1
141
+ end
142
+
143
+ # ------------------------------------------------------------------
144
+ # Existing helpers
145
+ # ------------------------------------------------------------------
146
+
147
+ def eager_load!
148
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
149
+
150
+ Rails.application.eager_load!
151
+ rescue StandardError => e
152
+ warn "[RailsVisualizer] Warning: eager_load! raised #{e.class}: #{e.message}"
153
+ nil
154
+ end
155
+
156
+ def app_name
157
+ Rails.application.class.module_parent_name
158
+ rescue StandardError
159
+ nil
160
+ end
161
+
162
+ def collect_models
163
+ ActiveRecord::Base.descendants.reject do |model|
164
+ model.abstract_class? ||
165
+ excluded?(model) ||
166
+ !model.name ||
167
+ anonymous?(model)
168
+ end
169
+ rescue NameError
170
+ []
171
+ end
172
+
173
+ def excluded?(model)
174
+ excluded_set.include?(model.name)
175
+ end
176
+
177
+ def excluded_set
178
+ @excluded_set ||= @configuration.excluded_models.to_set
179
+ end
180
+
181
+ def anonymous?(model)
182
+ model.name.start_with?('#<')
183
+ end
184
+
185
+ def safe_table_name(model)
186
+ model.table_name
187
+ rescue StandardError
188
+ nil
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsVisualizer
4
+ class JobsInspector
5
+ include RailsVisualizer::PathHelper
6
+
7
+ # Namespaces whose jobs come from Rails or popular gems, not user code.
8
+ FRAMEWORK_NAMESPACES = %w[
9
+ ActiveJob::
10
+ ActionMailer::
11
+ ActiveStorage::
12
+ ActionMailbox::
13
+ Turbo::
14
+ ActiveRecord::
15
+ ].freeze
16
+
17
+ # Conventional application base-class names.
18
+ BASE_JOB_NAMES = %w[ApplicationJob].freeze
19
+
20
+ def initialize(excluded: [])
21
+ @excluded_set = excluded.to_set
22
+ end
23
+
24
+ def call
25
+ {
26
+ adapter: detect_adapter,
27
+ active_jobs: collect_active_jobs,
28
+ sidekiq_workers: collect_sidekiq_workers
29
+ }
30
+ end
31
+
32
+ private
33
+
34
+ def detect_adapter
35
+ ActiveJob::Base.queue_adapter_name.to_s
36
+ rescue StandardError
37
+ 'unknown'
38
+ end
39
+
40
+ def collect_active_jobs
41
+ return [] unless defined?(ActiveJob::Base)
42
+
43
+ ActiveJob::Base
44
+ .descendants
45
+ .reject { |j| j.name.nil? || framework_class?(j.name) || base_job?(j.name) || @excluded_set.include?(j.name) }
46
+ .map { |j| serialize_active_job(j) }
47
+ rescue StandardError
48
+ []
49
+ end
50
+
51
+ def serialize_active_job(job)
52
+ {
53
+ name: job.name,
54
+ file_path: source_path_for(job),
55
+ queue: safe_queue_name(job),
56
+ priority: job.priority,
57
+ retry_on: extract_retry_config(job),
58
+ discard_on: extract_discard_config(job)
59
+ }
60
+ rescue StandardError
61
+ { name: job.name, file_path: nil, queue: 'default', priority: nil, retry_on: [], discard_on: [] }
62
+ end
63
+
64
+ # queue_name may be a Proc/lambda when set via a block (queue_as { ... }).
65
+ # Calling .to_s on a Proc produces an unreadable string; return 'dynamic'.
66
+ def safe_queue_name(job)
67
+ name = job.queue_name
68
+ name.is_a?(String) ? name : 'dynamic'
69
+ rescue StandardError
70
+ 'default'
71
+ end
72
+
73
+ def extract_retry_config(job)
74
+ job.rescue_handlers
75
+ .map { |h| { error: h[0].to_s, handler: h[1].class.name } }
76
+ rescue StandardError
77
+ []
78
+ end
79
+
80
+ def extract_discard_config(job)
81
+ return [] unless job.respond_to?(:discard_on_handlers)
82
+
83
+ job.discard_on_handlers.map(&:to_s)
84
+ rescue StandardError
85
+ []
86
+ end
87
+
88
+ def collect_sidekiq_workers
89
+ return [] unless defined?(Sidekiq)
90
+
91
+ worker_module = defined?(Sidekiq::Job) ? Sidekiq::Job : Sidekiq::Worker
92
+
93
+ ObjectSpace.each_object(Class)
94
+ .select { |c| c < worker_module && !c.name.nil? }
95
+ .reject { |c| framework_class?(c.name) || base_job?(c.name) || @excluded_set.include?(c.name) }
96
+ .map { |c| serialize_sidekiq_worker(c) }
97
+ rescue StandardError
98
+ []
99
+ end
100
+
101
+ def serialize_sidekiq_worker(worker)
102
+ opts = worker.respond_to?(:sidekiq_options) ? worker.sidekiq_options : {}
103
+ {
104
+ name: worker.name,
105
+ file_path: source_path_for(worker),
106
+ queue: opts['queue'] || 'default',
107
+ retry: opts['retry'],
108
+ unique: opts['unique']
109
+ }
110
+ end
111
+
112
+ def framework_class?(name)
113
+ FRAMEWORK_NAMESPACES.any? { |ns| name.start_with?(ns) }
114
+ end
115
+
116
+ def base_job?(name)
117
+ BASE_JOB_NAMES.include?(name)
118
+ end
119
+ end
120
+ end