rails-ai-context 4.3.2 → 4.4.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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +127 -53
  3. data/CLAUDE.md +3 -1
  4. data/README.md +268 -197
  5. data/demo-trace.gif +0 -0
  6. data/demo-trace.tape +21 -0
  7. data/demo.gif +0 -0
  8. data/demo.tape +33 -0
  9. data/docs/GUIDE.md +9 -9
  10. data/lib/generators/rails_ai_context/install/install_generator.rb +2 -1
  11. data/lib/rails_ai_context/cli/tool_runner.rb +1 -1
  12. data/lib/rails_ai_context/configuration.rb +25 -1
  13. data/lib/rails_ai_context/doctor.rb +4 -2
  14. data/lib/rails_ai_context/fingerprinter.rb +6 -1
  15. data/lib/rails_ai_context/introspectors/accessibility_introspector.rb +52 -1
  16. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +10 -2
  17. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +22 -2
  18. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +50 -4
  19. data/lib/rails_ai_context/introspectors/api_introspector.rb +41 -5
  20. data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +10 -4
  21. data/lib/rails_ai_context/introspectors/auth_introspector.rb +62 -7
  22. data/lib/rails_ai_context/introspectors/component_introspector.rb +6 -0
  23. data/lib/rails_ai_context/introspectors/config_introspector.rb +59 -9
  24. data/lib/rails_ai_context/introspectors/controller_introspector.rb +45 -13
  25. data/lib/rails_ai_context/introspectors/convention_detector.rb +25 -2
  26. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +58 -4
  27. data/lib/rails_ai_context/introspectors/design_token_introspector.rb +27 -5
  28. data/lib/rails_ai_context/introspectors/devops_introspector.rb +15 -8
  29. data/lib/rails_ai_context/introspectors/engine_introspector.rb +12 -3
  30. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +36 -1
  31. data/lib/rails_ai_context/introspectors/gem_introspector.rb +47 -1
  32. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +49 -3
  33. data/lib/rails_ai_context/introspectors/job_introspector.rb +48 -5
  34. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +24 -3
  35. data/lib/rails_ai_context/introspectors/migration_introspector.rb +4 -1
  36. data/lib/rails_ai_context/introspectors/model_introspector.rb +108 -11
  37. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +57 -12
  38. data/lib/rails_ai_context/introspectors/performance_introspector.rb +34 -9
  39. data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +12 -2
  40. data/lib/rails_ai_context/introspectors/route_introspector.rb +25 -8
  41. data/lib/rails_ai_context/introspectors/schema_introspector.rb +45 -7
  42. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +5 -2
  43. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +59 -6
  44. data/lib/rails_ai_context/introspectors/test_introspector.rb +50 -5
  45. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +44 -13
  46. data/lib/rails_ai_context/introspectors/view_introspector.rb +46 -7
  47. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +25 -7
  48. data/lib/rails_ai_context/resources.rb +1 -1
  49. data/lib/rails_ai_context/server.rb +6 -3
  50. data/lib/rails_ai_context/tasks/rails_ai_context.rake +8 -4
  51. data/lib/rails_ai_context/tools/analyze_feature.rb +66 -19
  52. data/lib/rails_ai_context/tools/base_tool.rb +1 -1
  53. data/lib/rails_ai_context/tools/diagnose.rb +4 -2
  54. data/lib/rails_ai_context/tools/get_callbacks.rb +4 -2
  55. data/lib/rails_ai_context/tools/get_concern.rb +12 -6
  56. data/lib/rails_ai_context/tools/get_controllers.rb +10 -5
  57. data/lib/rails_ai_context/tools/get_conventions.rb +4 -2
  58. data/lib/rails_ai_context/tools/get_design_system.rb +2 -1
  59. data/lib/rails_ai_context/tools/get_env.rb +8 -4
  60. data/lib/rails_ai_context/tools/get_helper_methods.rb +6 -3
  61. data/lib/rails_ai_context/tools/get_job_pattern.rb +2 -1
  62. data/lib/rails_ai_context/tools/get_model_details.rb +10 -5
  63. data/lib/rails_ai_context/tools/get_partial_interface.rb +14 -7
  64. data/lib/rails_ai_context/tools/get_schema.rb +2 -1
  65. data/lib/rails_ai_context/tools/get_service_pattern.rb +2 -1
  66. data/lib/rails_ai_context/tools/get_stimulus.rb +2 -1
  67. data/lib/rails_ai_context/tools/get_test_info.rb +4 -2
  68. data/lib/rails_ai_context/tools/get_turbo_map.rb +22 -11
  69. data/lib/rails_ai_context/tools/get_view.rb +6 -3
  70. data/lib/rails_ai_context/tools/migration_advisor.rb +2 -1
  71. data/lib/rails_ai_context/tools/onboard.rb +2 -1
  72. data/lib/rails_ai_context/tools/performance_check.rb +2 -1
  73. data/lib/rails_ai_context/tools/query.rb +5 -1
  74. data/lib/rails_ai_context/tools/read_logs.rb +3 -0
  75. data/lib/rails_ai_context/tools/runtime_info.rb +10 -5
  76. data/lib/rails_ai_context/tools/search_code.rb +8 -4
  77. data/lib/rails_ai_context/tools/search_docs.rb +2 -1
  78. data/lib/rails_ai_context/tools/session_context.rb +2 -1
  79. data/lib/rails_ai_context/tools/validate.rb +16 -8
  80. data/lib/rails_ai_context/version.rb +1 -1
  81. metadata +5 -1
@@ -45,6 +45,14 @@ module RailsAiContext
45
45
  secure_pw = scan_models_for(/has_secure_password/)
46
46
  auth[:has_secure_password] = secure_pw.map { |m| m[:model] } if secure_pw.any?
47
47
 
48
+ # OmniAuth providers
49
+ omniauth = detect_omniauth_providers
50
+ auth[:omniauth_providers] = omniauth if omniauth.any?
51
+
52
+ # Devise settings (timeout, lockout, etc.)
53
+ devise_settings = extract_devise_settings
54
+ auth[:devise_settings] = devise_settings unless devise_settings.empty?
55
+
48
56
  auth
49
57
  end
50
58
 
@@ -103,7 +111,8 @@ module RailsAiContext
103
111
  end
104
112
 
105
113
  result
106
- rescue
114
+ rescue => e
115
+ $stderr.puts "[rails-ai-context] detect_devise_modules_per_model failed: #{e.message}" if ENV["DEBUG"]
107
116
  {}
108
117
  end
109
118
 
@@ -115,7 +124,8 @@ module RailsAiContext
115
124
  token_auth[:http_token_auth] = detect_http_token_auth
116
125
 
117
126
  token_auth
118
- rescue
127
+ rescue => e
128
+ $stderr.puts "[rails-ai-context] detect_token_auth failed: #{e.message}" if ENV["DEBUG"]
119
129
  {}
120
130
  end
121
131
 
@@ -129,7 +139,8 @@ module RailsAiContext
129
139
  end
130
140
 
131
141
  { detected: true }
132
- rescue
142
+ rescue => e
143
+ $stderr.puts "[rails-ai-context] detect_devise_jwt failed: #{e.message}" if ENV["DEBUG"]
133
144
  { detected: false }
134
145
  end
135
146
 
@@ -150,7 +161,8 @@ module RailsAiContext
150
161
  result[:grant_flows] = grant_flows if grant_flows
151
162
  result[:access_token_expires_in] = expires_in if expires_in
152
163
  result
153
- rescue
164
+ rescue => e
165
+ $stderr.puts "[rails-ai-context] detect_doorkeeper failed: #{e.message}" if ENV["DEBUG"]
154
166
  nil
155
167
  end
156
168
 
@@ -164,10 +176,51 @@ module RailsAiContext
164
176
  path.sub("#{root}/", "")
165
177
  end
166
178
  end.sort
167
- rescue
179
+ rescue => e
180
+ $stderr.puts "[rails-ai-context] detect_http_token_auth failed: #{e.message}" if ENV["DEBUG"]
181
+ []
182
+ end
183
+
184
+ def detect_omniauth_providers
185
+ providers = []
186
+ initializers = Dir.glob(File.join(app.root, "config", "initializers", "*.rb"))
187
+ initializers.each do |path|
188
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
189
+ content.scan(/config\.omniauth\s+:(\w+)/).each { |m| providers << m[0] }
190
+ content.scan(/provider\s+:(\w+)/).each { |m| providers << m[0] unless %w[developer].include?(m[0]) }
191
+ end
192
+ # Also check model files for omniauth_providers
193
+ models_dir = File.join(app.root, "app", "models")
194
+ if Dir.exist?(models_dir)
195
+ Dir.glob(File.join(models_dir, "**", "*.rb")).each do |path|
196
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
197
+ content.scan(/omniauth_providers:\s*\[([^\]]+)\]/).each do |m|
198
+ m[0].scan(/:(\w+)/).each { |p| providers << p[0] }
199
+ end
200
+ end
201
+ end
202
+ providers.uniq
203
+ rescue => e
204
+ $stderr.puts "[rails-ai-context] detect_omniauth_providers failed: #{e.message}" if ENV["DEBUG"]
168
205
  []
169
206
  end
170
207
 
208
+ def extract_devise_settings
209
+ path = File.join(app.root, "config", "initializers", "devise.rb")
210
+ return {} unless File.exist?(path)
211
+ content = File.read(path)
212
+ settings = {}
213
+ settings[:timeout_in] = $1 if content.match(/config\.timeout_in\s*=\s*(\S+)/)
214
+ settings[:lock_strategy] = $1 if content.match(/config\.lock_strategy\s*=\s*:(\w+)/)
215
+ settings[:maximum_attempts] = $1.to_i if content.match(/config\.maximum_attempts\s*=\s*(\d+)/)
216
+ settings[:unlock_strategy] = $1 if content.match(/config\.unlock_strategy\s*=\s*:(\w+)/)
217
+ settings[:password_length] = $1 if content.match(/config\.password_length\s*=\s*(\S+)/)
218
+ settings.empty? ? {} : settings
219
+ rescue => e
220
+ $stderr.puts "[rails-ai-context] extract_devise_settings failed: #{e.message}" if ENV["DEBUG"]
221
+ {}
222
+ end
223
+
171
224
  def scan_models_for(pattern)
172
225
  models_dir = File.join(root, "app/models")
173
226
  return [] unless Dir.exist?(models_dir)
@@ -182,7 +235,8 @@ module RailsAiContext
182
235
  results << { model: model_name, matches: matches.flatten.map(&:strip) }
183
236
  end
184
237
  results.sort_by { |r| r[:model] }
185
- rescue
238
+ rescue => e
239
+ $stderr.puts "[rails-ai-context] scan_models_for failed: #{e.message}" if ENV["DEBUG"]
186
240
  []
187
241
  end
188
242
 
@@ -190,7 +244,8 @@ module RailsAiContext
190
244
  lock_path = File.join(root, "Gemfile.lock")
191
245
  return false unless File.exist?(lock_path)
192
246
  File.read(lock_path).include?(" #{name} (")
193
- rescue
247
+ rescue => e
248
+ $stderr.puts "[rails-ai-context] gem_present? failed: #{e.message}" if ENV["DEBUG"]
194
249
  false
195
250
  end
196
251
 
@@ -150,6 +150,12 @@ module RailsAiContext
150
150
  props << prop
151
151
  end
152
152
 
153
+ # Detect **kwargs / **options splat
154
+ if params_str.match?(/\*\*(\w+)/)
155
+ splat_name = params_str.match(/\*\*(\w+)/)[1]
156
+ props << { name: splat_name, splat: true }
157
+ end
158
+
153
159
  props
154
160
  end
155
161
 
@@ -12,7 +12,7 @@ module RailsAiContext
12
12
  end
13
13
 
14
14
  def call
15
- {
15
+ result = {
16
16
  cache_store: detect_cache_store,
17
17
  session_store: detect_session_store,
18
18
  timezone: app.config.time_zone.to_s,
@@ -21,8 +21,19 @@ module RailsAiContext
21
21
  middleware_stack: extract_middleware,
22
22
  initializers: extract_initializers,
23
23
  credentials_configured: credentials_configured?,
24
- current_attributes: detect_current_attributes
25
- }.compact
24
+ current_attributes: detect_current_attributes,
25
+ error_monitoring: detect_error_monitoring,
26
+ job_processor: detect_job_processor_config
27
+ }
28
+
29
+ # Extract cache store options when configured as an Array
30
+ if app.config.cache_store.is_a?(Array) && app.config.cache_store.size > 1
31
+ opts = app.config.cache_store[1..]
32
+ cache_opts = opts.last.is_a?(Hash) ? opts.last.keys.map(&:to_s) : []
33
+ result[:cache_store_options] = cache_opts if cache_opts.any?
34
+ end
35
+
36
+ result.compact
26
37
  rescue => e
27
38
  { error: e.message }
28
39
  end
@@ -40,7 +51,8 @@ module RailsAiContext
40
51
  when Array then store.first.to_s
41
52
  else store.class.name
42
53
  end
43
- rescue
54
+ rescue => e
55
+ $stderr.puts "[rails-ai-context] detect_cache_store failed: #{e.message}" if ENV["DEBUG"]
44
56
  "unknown"
45
57
  end
46
58
 
@@ -55,7 +67,8 @@ module RailsAiContext
55
67
  when Class then adapter.name
56
68
  else adapter.to_s
57
69
  end
58
- rescue
70
+ rescue => e
71
+ $stderr.puts "[rails-ai-context] detect_queue_adapter failed: #{e.message}" if ENV["DEBUG"]
59
72
  "unknown"
60
73
  end
61
74
 
@@ -78,13 +91,15 @@ module RailsAiContext
78
91
  end
79
92
 
80
93
  settings.empty? ? nil : settings
81
- rescue
94
+ rescue => e
95
+ $stderr.puts "[rails-ai-context] detect_mailer_settings failed: #{e.message}" if ENV["DEBUG"]
82
96
  nil
83
97
  end
84
98
 
85
99
  def extract_middleware
86
100
  app.middleware.map { |m| m.name || m.klass.to_s }.uniq
87
- rescue
101
+ rescue => e
102
+ $stderr.puts "[rails-ai-context] extract_middleware failed: #{e.message}" if ENV["DEBUG"]
88
103
  []
89
104
  end
90
105
 
@@ -100,7 +115,8 @@ module RailsAiContext
100
115
  def credentials_configured?
101
116
  creds = app.credentials
102
117
  creds.respond_to?(:config) && creds.config.keys.any?
103
- rescue
118
+ rescue => e
119
+ $stderr.puts "[rails-ai-context] credentials_configured? failed: #{e.message}" if ENV["DEBUG"]
104
120
  false
105
121
  end
106
122
 
@@ -113,10 +129,44 @@ module RailsAiContext
113
129
  if content.match?(/< ActiveSupport::CurrentAttributes|< Rails::CurrentAttributes/)
114
130
  File.basename(path, ".rb").camelize
115
131
  end
116
- rescue
132
+ rescue => e
133
+ $stderr.puts "[rails-ai-context] detect_current_attributes failed: #{e.message}" if ENV["DEBUG"]
117
134
  nil
118
135
  end
119
136
  end
137
+
138
+ def detect_error_monitoring
139
+ gemfile_lock = File.join(app.root, "Gemfile.lock")
140
+ return nil unless File.exist?(gemfile_lock)
141
+ content = File.read(gemfile_lock)
142
+
143
+ tools = []
144
+ tools << "sentry" if content.include?("sentry-ruby") || content.include?("sentry-rails")
145
+ tools << "bugsnag" if content.include?("bugsnag")
146
+ tools << "honeybadger" if content.include?("honeybadger")
147
+ tools << "rollbar" if content.include?("rollbar")
148
+ tools << "airbrake" if content.include?("airbrake")
149
+ tools << "appsignal" if content.include?("appsignal")
150
+ tools.empty? ? nil : tools
151
+ rescue => e
152
+ $stderr.puts "[rails-ai-context] detect_error_monitoring failed: #{e.message}" if ENV["DEBUG"]
153
+ nil
154
+ end
155
+
156
+ def detect_job_processor_config
157
+ config = {}
158
+ sidekiq_path = File.join(app.root, "config", "sidekiq.yml")
159
+ if File.exist?(sidekiq_path)
160
+ content = File.read(sidekiq_path)
161
+ config[:processor] = "sidekiq"
162
+ config[:concurrency] = $1.to_i if content.match(/concurrency:\s*(\d+)/)
163
+ config[:queues] = content.scan(/-\s+(\w+)/).flatten.uniq
164
+ end
165
+ config.empty? ? nil : config
166
+ rescue => e
167
+ $stderr.puts "[rails-ai-context] detect_job_processor_config failed: #{e.message}" if ENV["DEBUG"]
168
+ nil
169
+ end
120
170
  end
121
171
  end
122
172
  end
@@ -51,7 +51,8 @@ module RailsAiContext
51
51
  else
52
52
  Rails.application.eager_load!
53
53
  end
54
- rescue
54
+ rescue => e
55
+ $stderr.puts "[rails-ai-context] eager_load_controllers! failed: #{e.message}" if ENV["DEBUG"]
55
56
  nil
56
57
  end
57
58
 
@@ -85,7 +86,8 @@ module RailsAiContext
85
86
  def extract_details_from_source(path)
86
87
  source = File.read(path)
87
88
  parent = source.match(/class\s+\S+\s*<\s*(\S+)/)&.send(:[], 1) || "Unknown"
88
- {
89
+ rate_limit_raw = extract_rate_limit(source)
90
+ details = {
89
91
  parent_class: parent,
90
92
  api_controller: parent.include?("API"),
91
93
  actions: extract_actions_from_source(source),
@@ -94,15 +96,18 @@ module RailsAiContext
94
96
  strong_params: extract_strong_params(source),
95
97
  respond_to_formats: extract_respond_to(source),
96
98
  rescue_from: extract_rescue_from(source),
97
- rate_limit: extract_rate_limit(source),
99
+ rate_limit: rate_limit_raw,
100
+ rate_limit_parsed: parse_rate_limit(rate_limit_raw),
98
101
  turbo_stream_actions: extract_turbo_stream_actions(source)
99
102
  }.compact
103
+ details
100
104
  rescue => e
101
105
  { error: e.message }
102
106
  end
103
107
 
104
108
  def extract_controller_details(ctrl)
105
109
  source = read_source(ctrl)
110
+ rate_limit_raw = extract_rate_limit(source)
106
111
 
107
112
  {
108
113
  parent_class: ctrl.superclass.name,
@@ -113,7 +118,8 @@ module RailsAiContext
113
118
  strong_params: extract_strong_params(source),
114
119
  respond_to_formats: extract_respond_to(source),
115
120
  rescue_from: extract_rescue_from(source),
116
- rate_limit: extract_rate_limit(source),
121
+ rate_limit: rate_limit_raw,
122
+ rate_limit_parsed: parse_rate_limit(rate_limit_raw),
117
123
  turbo_stream_actions: extract_turbo_stream_actions(source)
118
124
  }.compact
119
125
  end
@@ -131,7 +137,8 @@ module RailsAiContext
131
137
  return actions if actions.any?
132
138
  end
133
139
  ctrl.action_methods.to_a.sort
134
- rescue
140
+ rescue => e
141
+ $stderr.puts "[rails-ai-context] extract_actions failed: #{e.message}" if ENV["DEBUG"]
135
142
  []
136
143
  end
137
144
 
@@ -148,6 +155,9 @@ module RailsAiContext
148
155
 
149
156
  next if in_private
150
157
 
158
+ # Skip inline private/protected method definitions (e.g. `private def foo`)
159
+ next if line.match?(/\A\s*(?:private|protected)\s+def\s/)
160
+
151
161
  if (match = line.match(/\A\s*def\s+(\w+[?!]?)/))
152
162
  actions << match[1] unless match[1].start_with?("_")
153
163
  end
@@ -192,7 +202,8 @@ module RailsAiContext
192
202
  end
193
203
 
194
204
  []
195
- rescue
205
+ rescue => e
206
+ $stderr.puts "[rails-ai-context] extract_filters failed: #{e.message}" if ENV["DEBUG"]
196
207
  []
197
208
  end
198
209
 
@@ -215,7 +226,8 @@ module RailsAiContext
215
226
  klass = klass.superclass
216
227
  end
217
228
  constraints
218
- rescue
229
+ rescue => e
230
+ $stderr.puts "[rails-ai-context] collect_source_constraints failed: #{e.message}" if ENV["DEBUG"]
219
231
  {}
220
232
  end
221
233
 
@@ -287,7 +299,8 @@ module RailsAiContext
287
299
  def devise_controller?(ctrl)
288
300
  return false unless defined?(::DeviseController)
289
301
  ctrl < ::DeviseController || ctrl.ancestors.any? { |a| a.name&.start_with?("Devise::") }
290
- rescue
302
+ rescue => e
303
+ $stderr.puts "[rails-ai-context] devise_controller? failed: #{e.message}" if ENV["DEBUG"]
291
304
  false
292
305
  end
293
306
 
@@ -303,7 +316,8 @@ module RailsAiContext
303
316
  .reject { |mod| mod.name&.start_with?("ActionController", "ActionDispatch", "ActiveSupport", "AbstractController") }
304
317
  .map(&:name)
305
318
  .compact
306
- rescue
319
+ rescue => e
320
+ $stderr.puts "[rails-ai-context] extract_concerns failed: #{e.message}" if ENV["DEBUG"]
307
321
  []
308
322
  end
309
323
 
@@ -338,7 +352,8 @@ module RailsAiContext
338
352
  exceptions = exception_part&.scan(/([A-Z][\w:]+)/)&.flatten || []
339
353
  exceptions.map { |ex| { exception: ex, handler: handler }.compact }
340
354
  end.flatten
341
- rescue
355
+ rescue => e
356
+ $stderr.puts "[rails-ai-context] extract_rescue_from failed: #{e.message}" if ENV["DEBUG"]
342
357
  []
343
358
  end
344
359
 
@@ -349,7 +364,22 @@ module RailsAiContext
349
364
  return nil unless match
350
365
 
351
366
  match[1].strip
352
- rescue
367
+ rescue => e
368
+ $stderr.puts "[rails-ai-context] extract_rate_limit failed: #{e.message}" if ENV["DEBUG"]
369
+ nil
370
+ end
371
+
372
+ def parse_rate_limit(rate_limit_raw)
373
+ return nil if rate_limit_raw.nil?
374
+
375
+ parsed = {}
376
+ parsed[:to] = $1.to_i if rate_limit_raw.match(/to:\s*(\d+)/)
377
+ parsed[:within] = $1 if rate_limit_raw.match(/within:\s*(\S+)/)
378
+ parsed[:only] = $1.scan(/:(\w+)/).flatten if rate_limit_raw.match(/only:\s*(.+?)(?:,\s*\w+:|$)/)
379
+
380
+ parsed.empty? ? nil : parsed
381
+ rescue => e
382
+ $stderr.puts "[rails-ai-context] parse_rate_limit failed: #{e.message}" if ENV["DEBUG"]
353
383
  nil
354
384
  end
355
385
 
@@ -369,7 +399,8 @@ module RailsAiContext
369
399
  end
370
400
  end
371
401
  actions.uniq.sort
372
- rescue
402
+ rescue => e
403
+ $stderr.puts "[rails-ai-context] extract_turbo_stream_actions failed: #{e.message}" if ENV["DEBUG"]
373
404
  []
374
405
  end
375
406
 
@@ -377,7 +408,8 @@ module RailsAiContext
377
408
  path = source_path(ctrl)
378
409
  return nil unless path && File.exist?(path)
379
410
  File.read(path)
380
- rescue
411
+ rescue => e
412
+ $stderr.puts "[rails-ai-context] read_source failed: #{e.message}" if ENV["DEBUG"]
381
413
  nil
382
414
  end
383
415
 
@@ -58,6 +58,11 @@ module RailsAiContext
58
58
  %w[dry-validation dry-types dry-struct dry-monads].each do |gem|
59
59
  arch << "dry_rb" if gem_present?(gem)
60
60
  end
61
+ arch << "multi_tenant" if gem_present?("apartment") || gem_present?("acts_as_tenant") || gem_present?("ros-apartment")
62
+ arch << "feature_flags" if gem_present?("flipper") || gem_present?("launchdarkly-server-sdk") || gem_present?("split") || gem_present?("unleash")
63
+ arch << "error_monitoring" if gem_present?("sentry-ruby") || gem_present?("bugsnag") || gem_present?("honeybadger") || gem_present?("rollbar") || gem_present?("airbrake")
64
+ arch << "event_driven" if gem_present?("ruby-kafka") || gem_present?("karafka") || gem_present?("bunny") || gem_present?("sneakers") || gem_present?("aws-sdk-sns") || gem_present?("aws-sdk-sqs")
65
+ arch << "zeitwerk" if defined?(Zeitwerk) && defined?(Rails) && Rails.autoloaders.respond_to?(:main)
61
66
  arch.uniq
62
67
  end
63
68
 
@@ -70,7 +75,24 @@ module RailsAiContext
70
75
  model_files = Dir.glob(File.join(model_dir, "**/*.rb"))
71
76
  content = model_files.first(500).map { |f| File.read(f) rescue "" }.join("\n")
72
77
 
73
- patterns << "sti" if content.match?(/self\.inheritance_column|type.*string/)
78
+ # STI: explicit inheritance_column, or a model that inherits from another app model
79
+ # with a `type` column (verified via schema.rb or model source)
80
+ app_model_names = model_files.filter_map { |f| File.basename(f, ".rb").camelize }
81
+ schema_content = begin
82
+ schema_path = File.join(root, "db/schema.rb")
83
+ File.exist?(schema_path) ? File.read(schema_path) : ""
84
+ rescue
85
+ ""
86
+ end
87
+ has_sti_subclass = model_files.any? do |f|
88
+ src = File.read(f) rescue ""
89
+ parent_match = src.match(/class\s+\w+\s*<\s*(\w+)/)
90
+ next false unless parent_match && app_model_names.include?(parent_match[1]) && parent_match[1] != "ApplicationRecord"
91
+ # Verify parent's table has a `type` column
92
+ parent_table = parent_match[1].underscore.pluralize
93
+ schema_content.match?(/create_table\s+"#{Regexp.escape(parent_table)}".*?t\.\w+\s+"type"/m)
94
+ end
95
+ patterns << "sti" if content.match?(/self\.inheritance_column/) || has_sti_subclass
74
96
  patterns << "polymorphic" if content.match?(/polymorphic:\s*true/)
75
97
  patterns << "soft_delete" if content.match?(/acts_as_paranoid|discard|deleted_at/)
76
98
  patterns << "versioning" if content.match?(/has_paper_trail|audited/)
@@ -146,7 +168,8 @@ module RailsAiContext
146
168
  .select { |d| File.directory?(File.join(app_dir, d)) }
147
169
  .reject { |d| STANDARD_APP_DIRS.include?(d) }
148
170
  .sort
149
- rescue
171
+ rescue => e
172
+ $stderr.puts "[rails-ai-context] detect_custom_directories failed: #{e.message}" if ENV["DEBUG"]
150
173
  []
151
174
  end
152
175
 
@@ -15,23 +15,77 @@ module RailsAiContext
15
15
  return { skipped: true, reason: "ActiveRecord not available" } unless defined?(ActiveRecord::Base)
16
16
 
17
17
  adapter = ActiveRecord::Base.connection.adapter_name.downcase
18
- unless adapter.include?("postgresql")
19
- return { skipped: true, reason: "Only available for PostgreSQL (current: #{adapter})" }
18
+ case adapter
19
+ when /postgresql/
20
+ collect_postgresql_stats
21
+ when /mysql/
22
+ collect_mysql_stats
23
+ when /sqlite/
24
+ collect_sqlite_stats
25
+ else
26
+ { skipped: true, reason: "Stats not available for adapter: #{adapter}" }
20
27
  end
28
+ rescue => e
29
+ { error: e.message }
30
+ end
21
31
 
32
+ private
33
+
34
+ def collect_postgresql_stats
22
35
  rows = ActiveRecord::Base.connection.select_all(<<~SQL)
23
36
  SELECT relname AS table_name,
24
- n_live_tup AS approximate_row_count
37
+ n_live_tup AS approximate_row_count,
38
+ n_dead_tup AS dead_rows
25
39
  FROM pg_stat_user_tables
26
40
  ORDER BY n_live_tup DESC
27
41
  SQL
28
42
 
29
43
  tables = rows.map do |row|
30
- { table: row["table_name"], approximate_rows: row["approximate_row_count"].to_i }
44
+ entry = { table: row["table_name"], approximate_rows: row["approximate_row_count"].to_i }
45
+ dead = row["dead_rows"].to_i
46
+ entry[:dead_rows] = dead if dead > 0
47
+ entry
31
48
  end
32
49
 
33
50
  { adapter: "postgresql", tables: tables, total_tables: tables.size }
34
51
  rescue => e
52
+ $stderr.puts "[rails-ai-context] collect_postgresql_stats failed: #{e.message}" if ENV["DEBUG"]
53
+ { error: e.message }
54
+ end
55
+
56
+ def collect_mysql_stats
57
+ rows = ActiveRecord::Base.connection.select_all(<<~SQL)
58
+ SELECT TABLE_NAME AS table_name,
59
+ TABLE_ROWS AS approximate_row_count
60
+ FROM information_schema.TABLES
61
+ WHERE TABLE_SCHEMA = DATABASE()
62
+ AND TABLE_TYPE = 'BASE TABLE'
63
+ ORDER BY TABLE_ROWS DESC
64
+ SQL
65
+
66
+ tables = rows.map do |row|
67
+ { table: row["table_name"], approximate_rows: row["approximate_row_count"].to_i }
68
+ end
69
+
70
+ { adapter: "mysql", tables: tables, total_tables: tables.size }
71
+ rescue => e
72
+ $stderr.puts "[rails-ai-context] collect_mysql_stats failed: #{e.message}" if ENV["DEBUG"]
73
+ { error: e.message }
74
+ end
75
+
76
+ def collect_sqlite_stats
77
+ conn = ActiveRecord::Base.connection
78
+ # Use conn.tables as authoritative list — never interpolate user input
79
+ table_names = conn.tables.reject { |t| t.start_with?("ar_internal_metadata", "schema_migrations") }
80
+
81
+ tables = table_names.map do |table|
82
+ count = conn.select_value("SELECT COUNT(*) FROM #{conn.quote_table_name(table)}").to_i
83
+ { table: table, approximate_rows: count }
84
+ end.sort_by { |t| -t[:approximate_rows] }
85
+
86
+ { adapter: "sqlite", tables: tables, total_tables: tables.size }
87
+ rescue => e
88
+ $stderr.puts "[rails-ai-context] collect_sqlite_stats failed: #{e.message}" if ENV["DEBUG"]
35
89
  { error: e.message }
36
90
  end
37
91
  end
@@ -42,7 +42,8 @@ module RailsAiContext
42
42
  categorized: categorize_tokens(tokens),
43
43
  font_loading: extract_font_loading(root),
44
44
  css_layers: extract_css_layers(root),
45
- postcss_plugins: extract_postcss_plugins(root)
45
+ postcss_plugins: extract_postcss_plugins(root),
46
+ arbitrary_values: extract_arbitrary_values
46
47
  }
47
48
  rescue => e
48
49
  { error: e.message }
@@ -75,7 +76,8 @@ module RailsAiContext
75
76
  plugins << "headlessui" if pkg_content.include?("headlessui") || pkg_content.include?("@headlessui")
76
77
 
77
78
  plugins.any? ? "#{framework}+#{plugins.join('+')}" : framework
78
- rescue
79
+ rescue => e
80
+ $stderr.puts "[rails-ai-context] detect_framework failed: #{e.message}" if ENV["DEBUG"]
79
81
  "unknown"
80
82
  end
81
83
 
@@ -288,7 +290,8 @@ module RailsAiContext
288
290
  end
289
291
 
290
292
  result
291
- rescue
293
+ rescue => e
294
+ $stderr.puts "[rails-ai-context] extract_font_loading failed: #{e.message}" if ENV["DEBUG"]
292
295
  { font_face: 0, google_fonts: false, system_fonts: false }
293
296
  end
294
297
 
@@ -304,7 +307,8 @@ module RailsAiContext
304
307
  end
305
308
  end
306
309
  layers.uniq
307
- rescue
310
+ rescue => e
311
+ $stderr.puts "[rails-ai-context] extract_css_layers failed: #{e.message}" if ENV["DEBUG"]
308
312
  []
309
313
  end
310
314
 
@@ -325,10 +329,28 @@ module RailsAiContext
325
329
  end
326
330
  end
327
331
  plugins.uniq
328
- rescue
332
+ rescue => e
333
+ $stderr.puts "[rails-ai-context] extract_postcss_plugins failed: #{e.message}" if ENV["DEBUG"]
329
334
  []
330
335
  end
331
336
 
337
+ def extract_arbitrary_values
338
+ values = Hash.new(0)
339
+ views_dir = File.join(app.root.to_s, "app", "views")
340
+ return values unless Dir.exist?(views_dir)
341
+
342
+ Dir.glob(File.join(views_dir, "**", "*.{erb,haml,slim}")).first(100).each do |path|
343
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
344
+ content.scan(/\b(\w+)-\[([^\]]+)\]/).each do |prefix, _value|
345
+ values["#{prefix}-[...]"] += 1
346
+ end
347
+ end
348
+ values.sort_by { |_, count| -count }.first(20).to_h
349
+ rescue => e
350
+ $stderr.puts "[rails-ai-context] extract_arbitrary_values failed: #{e.message}" if ENV["DEBUG"]
351
+ {}
352
+ end
353
+
332
354
  # Helper: extract :root { --var: value } from CSS content
333
355
  def extract_root_vars(content, tokens)
334
356
  content.scan(/:root\s*(?:,\s*:host)?\s*\{([^}]+)\}/m).each do |match|
@@ -50,7 +50,8 @@ module RailsAiContext
50
50
  end
51
51
 
52
52
  config.empty? ? nil : config
53
- rescue
53
+ rescue => e
54
+ $stderr.puts "[rails-ai-context] extract_puma_config failed: #{e.message}" if ENV["DEBUG"]
54
55
  nil
55
56
  end
56
57
 
@@ -77,7 +78,8 @@ module RailsAiContext
77
78
  content = File.read(routes_path)
78
79
  return true if content.match?(/\b(?:health|up|ping|status)\b/)
79
80
  nil
80
- rescue
81
+ rescue => e
82
+ $stderr.puts "[rails-ai-context] detect_health_check failed: #{e.message}" if ENV["DEBUG"]
81
83
  nil
82
84
  end
83
85
 
@@ -92,19 +94,24 @@ module RailsAiContext
92
94
  info[:base_images] = from_lines.flatten if from_lines.any?
93
95
  info[:multi_stage] = from_lines.size > 1
94
96
 
95
- compose = File.exist?(File.join(root, "docker-compose.yml"))
97
+ compose = File.exist?(File.join(root, "docker-compose.yml")) || File.exist?(File.join(root, "docker-compose.yaml"))
96
98
  info[:compose] = compose
97
99
 
98
100
  info
99
- rescue
101
+ rescue => e
102
+ $stderr.puts "[rails-ai-context] extract_docker_info failed: #{e.message}" if ENV["DEBUG"]
100
103
  nil
101
104
  end
102
105
 
103
106
  def detect_deployment_tool
104
- return "kamal" if File.exist?(File.join(root, "config/deploy.yml"))
105
- return "capistrano" if File.exist?(File.join(root, "Capfile"))
106
- return "heroku" if File.exist?(File.join(root, "app.json")) || File.exist?(File.join(root, "Procfile"))
107
- nil
107
+ tools = []
108
+ tools << "kamal" if File.exist?(File.join(root, "config/deploy.yml"))
109
+ tools << "capistrano" if File.exist?(File.join(root, "Capfile"))
110
+ tools << "heroku" if File.exist?(File.join(root, "app.json"))
111
+ tools << "fly.io" if File.exist?(File.join(root, "fly.toml"))
112
+ tools << "render" if File.exist?(File.join(root, "render.yaml")) || File.exist?(File.join(root, "render.yml"))
113
+ tools << "railway" if File.exist?(File.join(root, "railway.toml")) || File.exist?(File.join(root, "railway.json"))
114
+ tools.first # Return primary detected tool for backward compatibility
108
115
  end
109
116
  end
110
117
  end