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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +127 -53
- data/CLAUDE.md +3 -1
- data/README.md +268 -197
- data/demo-trace.gif +0 -0
- data/demo-trace.tape +21 -0
- data/demo.gif +0 -0
- data/demo.tape +33 -0
- data/docs/GUIDE.md +9 -9
- data/lib/generators/rails_ai_context/install/install_generator.rb +2 -1
- data/lib/rails_ai_context/cli/tool_runner.rb +1 -1
- data/lib/rails_ai_context/configuration.rb +25 -1
- data/lib/rails_ai_context/doctor.rb +4 -2
- data/lib/rails_ai_context/fingerprinter.rb +6 -1
- data/lib/rails_ai_context/introspectors/accessibility_introspector.rb +52 -1
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +10 -2
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +22 -2
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +50 -4
- data/lib/rails_ai_context/introspectors/api_introspector.rb +41 -5
- data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +10 -4
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +62 -7
- data/lib/rails_ai_context/introspectors/component_introspector.rb +6 -0
- data/lib/rails_ai_context/introspectors/config_introspector.rb +59 -9
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +45 -13
- data/lib/rails_ai_context/introspectors/convention_detector.rb +25 -2
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +58 -4
- data/lib/rails_ai_context/introspectors/design_token_introspector.rb +27 -5
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +15 -8
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +12 -3
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +36 -1
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +47 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +49 -3
- data/lib/rails_ai_context/introspectors/job_introspector.rb +48 -5
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +24 -3
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +4 -1
- data/lib/rails_ai_context/introspectors/model_introspector.rb +108 -11
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +57 -12
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +34 -9
- data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +12 -2
- data/lib/rails_ai_context/introspectors/route_introspector.rb +25 -8
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +45 -7
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +5 -2
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +59 -6
- data/lib/rails_ai_context/introspectors/test_introspector.rb +50 -5
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +44 -13
- data/lib/rails_ai_context/introspectors/view_introspector.rb +46 -7
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +25 -7
- data/lib/rails_ai_context/resources.rb +1 -1
- data/lib/rails_ai_context/server.rb +6 -3
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +8 -4
- data/lib/rails_ai_context/tools/analyze_feature.rb +66 -19
- data/lib/rails_ai_context/tools/base_tool.rb +1 -1
- data/lib/rails_ai_context/tools/diagnose.rb +4 -2
- data/lib/rails_ai_context/tools/get_callbacks.rb +4 -2
- data/lib/rails_ai_context/tools/get_concern.rb +12 -6
- data/lib/rails_ai_context/tools/get_controllers.rb +10 -5
- data/lib/rails_ai_context/tools/get_conventions.rb +4 -2
- data/lib/rails_ai_context/tools/get_design_system.rb +2 -1
- data/lib/rails_ai_context/tools/get_env.rb +8 -4
- data/lib/rails_ai_context/tools/get_helper_methods.rb +6 -3
- data/lib/rails_ai_context/tools/get_job_pattern.rb +2 -1
- data/lib/rails_ai_context/tools/get_model_details.rb +10 -5
- data/lib/rails_ai_context/tools/get_partial_interface.rb +14 -7
- data/lib/rails_ai_context/tools/get_schema.rb +2 -1
- data/lib/rails_ai_context/tools/get_service_pattern.rb +2 -1
- data/lib/rails_ai_context/tools/get_stimulus.rb +2 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +4 -2
- data/lib/rails_ai_context/tools/get_turbo_map.rb +22 -11
- data/lib/rails_ai_context/tools/get_view.rb +6 -3
- data/lib/rails_ai_context/tools/migration_advisor.rb +2 -1
- data/lib/rails_ai_context/tools/onboard.rb +2 -1
- data/lib/rails_ai_context/tools/performance_check.rb +2 -1
- data/lib/rails_ai_context/tools/query.rb +5 -1
- data/lib/rails_ai_context/tools/read_logs.rb +3 -0
- data/lib/rails_ai_context/tools/runtime_info.rb +10 -5
- data/lib/rails_ai_context/tools/search_code.rb +8 -4
- data/lib/rails_ai_context/tools/search_docs.rb +2 -1
- data/lib/rails_ai_context/tools/session_context.rb +2 -1
- data/lib/rails_ai_context/tools/validate.rb +16 -8
- data/lib/rails_ai_context/version.rb +1 -1
- 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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|