rails-ai-context 4.2.3 → 4.3.1
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 +54 -0
- data/CLAUDE.md +4 -4
- data/CONTRIBUTING.md +1 -1
- data/README.md +7 -7
- data/SECURITY.md +2 -1
- data/docs/GUIDE.md +3 -3
- data/lib/generators/rails_ai_context/install/install_generator.rb +2 -2
- data/lib/rails_ai_context/configuration.rb +4 -2
- data/lib/rails_ai_context/doctor.rb +6 -1
- data/lib/rails_ai_context/fingerprinter.rb +24 -0
- data/lib/rails_ai_context/introspectors/component_introspector.rb +122 -7
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +18 -10
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +183 -6
- data/lib/rails_ai_context/introspectors/view_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +61 -8
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +10 -19
- data/lib/rails_ai_context/serializers/claude_serializer.rb +42 -11
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +14 -3
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/design_system_helper.rb +8 -1
- data/lib/rails_ai_context/serializers/opencode_serializer.rb +5 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +165 -64
- data/lib/rails_ai_context/server.rb +12 -1
- data/lib/rails_ai_context/tools/base_tool.rb +63 -1
- data/lib/rails_ai_context/tools/diagnose.rb +436 -0
- data/lib/rails_ai_context/tools/generate_test.rb +571 -0
- data/lib/rails_ai_context/tools/get_callbacks.rb +27 -4
- data/lib/rails_ai_context/tools/get_component_catalog.rb +11 -2
- data/lib/rails_ai_context/tools/get_context.rb +70 -8
- data/lib/rails_ai_context/tools/get_conventions.rb +59 -0
- data/lib/rails_ai_context/tools/get_design_system.rb +45 -7
- data/lib/rails_ai_context/tools/get_edit_context.rb +3 -2
- data/lib/rails_ai_context/tools/get_env.rb +51 -24
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +100 -9
- data/lib/rails_ai_context/tools/get_model_details.rb +19 -0
- data/lib/rails_ai_context/tools/get_partial_interface.rb +1 -1
- data/lib/rails_ai_context/tools/get_stimulus.rb +13 -7
- data/lib/rails_ai_context/tools/get_turbo_map.rb +35 -2
- data/lib/rails_ai_context/tools/get_view.rb +65 -9
- data/lib/rails_ai_context/tools/migration_advisor.rb +4 -0
- data/lib/rails_ai_context/tools/onboard.rb +755 -0
- data/lib/rails_ai_context/tools/query.rb +4 -2
- data/lib/rails_ai_context/tools/read_logs.rb +4 -1
- data/lib/rails_ai_context/tools/review_changes.rb +299 -0
- data/lib/rails_ai_context/tools/runtime_info.rb +289 -0
- data/lib/rails_ai_context/tools/search_code.rb +23 -4
- data/lib/rails_ai_context/tools/security_scan.rb +7 -1
- data/lib/rails_ai_context/tools/session_context.rb +132 -0
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +10 -4
|
@@ -94,10 +94,18 @@ module RailsAiContext
|
|
|
94
94
|
lines << view_text
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
-
# Cross-reference: controller ivars vs
|
|
98
|
-
|
|
97
|
+
# Cross-reference: controller ivars vs view ivars
|
|
98
|
+
# Also check templates rendered by the action (e.g., create renders :new on failure)
|
|
99
|
+
ctrl_text = ctrl_result.content.first[:text]
|
|
100
|
+
ctrl_ivars = extract_ivars_from_text(ctrl_text)
|
|
99
101
|
view_ivars = extract_ivars_from_view_text(view_text, action: action_name)
|
|
100
|
-
|
|
102
|
+
# Detect "render :other_template" and include those templates' ivars too
|
|
103
|
+
rendered = ctrl_text.scan(/render\s+:(\w+)/).flatten.uniq
|
|
104
|
+
other_templates = rendered.reject { |t| t == action_name }
|
|
105
|
+
other_templates.each do |tmpl|
|
|
106
|
+
view_ivars.merge(extract_ivars_from_view_text(view_text, action: tmpl))
|
|
107
|
+
end
|
|
108
|
+
ivar_check = cross_reference_ivars(ctrl_ivars, view_ivars, rendered_templates: other_templates)
|
|
101
109
|
lines << "" << ivar_check if ivar_check
|
|
102
110
|
|
|
103
111
|
text_response(lines.join("\n"))
|
|
@@ -140,13 +148,13 @@ module RailsAiContext
|
|
|
140
148
|
ivars
|
|
141
149
|
end
|
|
142
150
|
|
|
143
|
-
private_class_method def self.cross_reference_ivars(ctrl_ivars, view_ivars)
|
|
151
|
+
private_class_method def self.cross_reference_ivars(ctrl_ivars, view_ivars, rendered_templates: [])
|
|
144
152
|
return nil if ctrl_ivars.empty? && view_ivars.empty?
|
|
145
153
|
|
|
146
154
|
lines = [ "## Instance Variable Cross-Check" ]
|
|
147
155
|
all = (ctrl_ivars | view_ivars).sort
|
|
148
156
|
|
|
149
|
-
|
|
157
|
+
missing_ivars = []
|
|
150
158
|
all.each do |ivar|
|
|
151
159
|
in_ctrl = ctrl_ivars.include?(ivar)
|
|
152
160
|
in_view = view_ivars.include?(ivar)
|
|
@@ -154,13 +162,21 @@ module RailsAiContext
|
|
|
154
162
|
lines << "- \u2713 @#{ivar} — set in controller, used in view"
|
|
155
163
|
elsif in_view && !in_ctrl
|
|
156
164
|
lines << "- \u2717 @#{ivar} — used in view but NOT set in controller"
|
|
157
|
-
|
|
165
|
+
missing_ivars << ivar
|
|
158
166
|
elsif in_ctrl && !in_view
|
|
159
167
|
lines << "- \u26A0 @#{ivar} — set in controller but not used in view"
|
|
160
168
|
end
|
|
161
169
|
end
|
|
162
170
|
|
|
163
|
-
|
|
171
|
+
# If there are missing ivars AND this action renders another template,
|
|
172
|
+
# add a note explaining why — the other action likely sets them
|
|
173
|
+
if missing_ivars.any? && rendered_templates.any?
|
|
174
|
+
templates = rendered_templates.map { |t| "`#{t}`" }.join(", ")
|
|
175
|
+
lines << ""
|
|
176
|
+
lines << "_Note: This action renders #{templates} on failure — those ivars are likely set in the corresponding action(s)._"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
(missing_ivars.any? || all.any?) ? lines.join("\n") : nil
|
|
164
180
|
end
|
|
165
181
|
|
|
166
182
|
private_class_method def self.controller_context(controller_name)
|
|
@@ -215,7 +231,11 @@ module RailsAiContext
|
|
|
215
231
|
|
|
216
232
|
if key && models[key][:table_name]
|
|
217
233
|
schema_result = GetSchema.call(table: models[key][:table_name])
|
|
218
|
-
|
|
234
|
+
schema_text = schema_result.content.first[:text]
|
|
235
|
+
# Only append schema if it actually has useful data (not "not found")
|
|
236
|
+
unless schema_text.include?("not found") || schema_text.include?("Available:")
|
|
237
|
+
lines << "" << "---" << "" << schema_text
|
|
238
|
+
end
|
|
219
239
|
end
|
|
220
240
|
|
|
221
241
|
# Tests for this model
|
|
@@ -271,16 +291,58 @@ module RailsAiContext
|
|
|
271
291
|
ctx = begin; cached_context; rescue; nil; end
|
|
272
292
|
if ctx
|
|
273
293
|
models = ctx[:models] || {}
|
|
294
|
+
matched_tables = Set.new
|
|
295
|
+
|
|
274
296
|
models.each_key do |model_name|
|
|
275
297
|
next unless model_name.downcase.include?(feature_name.downcase)
|
|
276
298
|
table_name = models[model_name][:table_name]
|
|
277
299
|
next unless table_name
|
|
300
|
+
matched_tables << table_name
|
|
278
301
|
schema_result = GetSchema.call(table: table_name)
|
|
279
302
|
schema_text = schema_result.content.first[:text]
|
|
280
303
|
unless schema_text.include?("not found")
|
|
281
304
|
lines << "" << "---" << "" << schema_text
|
|
282
305
|
end
|
|
283
306
|
end
|
|
307
|
+
|
|
308
|
+
# Also include schema for related models (associated tables) if the
|
|
309
|
+
# primary model was found but the feature analysis missed controllers/services
|
|
310
|
+
analyze_text = analyze_result.content.first[:text]
|
|
311
|
+
has_controllers = analyze_text.include?("## Controllers")
|
|
312
|
+
unless has_controllers
|
|
313
|
+
# Check if any controllers or services reference this feature by name
|
|
314
|
+
controllers = ctx[:controllers]
|
|
315
|
+
if controllers.is_a?(Hash) && !controllers[:error]
|
|
316
|
+
related_ctrls = (controllers[:controllers] || []).select do |c|
|
|
317
|
+
c_name = c[:name] || ""
|
|
318
|
+
c_name.downcase.include?(feature_name.downcase) ||
|
|
319
|
+
c_name.downcase.include?(feature_name.singularize.downcase) ||
|
|
320
|
+
c_name.downcase.include?(feature_name.pluralize.downcase)
|
|
321
|
+
end
|
|
322
|
+
if related_ctrls.any?
|
|
323
|
+
lines << "" << "## Related Controllers (by name)"
|
|
324
|
+
related_ctrls.each do |c|
|
|
325
|
+
actions = (c[:actions] || []).map { |a| a.is_a?(Hash) ? a[:name] : a }.compact
|
|
326
|
+
lines << "- **#{c[:name]}** — #{actions.join(', ')}"
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Check services
|
|
332
|
+
services = ctx[:services] || ctx[:service_objects]
|
|
333
|
+
if services.is_a?(Hash) && !services[:error]
|
|
334
|
+
service_list = services[:services] || []
|
|
335
|
+
related_svcs = service_list.select do |s|
|
|
336
|
+
s_name = (s[:name] || s[:file] || "").to_s
|
|
337
|
+
s_name.downcase.include?(feature_name.downcase) ||
|
|
338
|
+
s_name.downcase.include?(feature_name.singularize.downcase)
|
|
339
|
+
end
|
|
340
|
+
if related_svcs.any?
|
|
341
|
+
lines << "" << "## Related Services (by name)"
|
|
342
|
+
related_svcs.each { |s| lines << "- `#{s[:file] || s[:name]}`" }
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
284
346
|
end
|
|
285
347
|
|
|
286
348
|
text_response(lines.join("\n"))
|
|
@@ -74,6 +74,13 @@ module RailsAiContext
|
|
|
74
74
|
end
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
# I18n / Locale info
|
|
78
|
+
locale_info = detect_locale_info
|
|
79
|
+
if locale_info.any?
|
|
80
|
+
lines << "" << "## I18n"
|
|
81
|
+
locale_info.each { |l| lines << "- #{l}" }
|
|
82
|
+
end
|
|
83
|
+
|
|
77
84
|
# Convention Fingerprint — one-paragraph summary of the app's detected conventions
|
|
78
85
|
fingerprint_parts = []
|
|
79
86
|
fingerprint_parts << conventions[:architecture].map { |a| humanize_arch(a) }.join(", ") if conventions[:architecture]&.any?
|
|
@@ -319,6 +326,58 @@ module RailsAiContext
|
|
|
319
326
|
[] # Graceful degradation — never break the tool
|
|
320
327
|
end
|
|
321
328
|
|
|
329
|
+
private_class_method def self.detect_locale_info
|
|
330
|
+
info = []
|
|
331
|
+
locales_dir = Rails.root.join("config", "locales")
|
|
332
|
+
return info unless Dir.exist?(locales_dir)
|
|
333
|
+
|
|
334
|
+
locale_files = Dir.glob(File.join(locales_dir, "**", "*.{yml,yaml,rb}"))
|
|
335
|
+
locales = locale_files.map { |f| File.basename(f, ".*").split(".").first }.uniq.sort
|
|
336
|
+
|
|
337
|
+
return info if locales.empty?
|
|
338
|
+
|
|
339
|
+
info << "**Locales:** #{locales.join(', ')} (#{locales.size} total)"
|
|
340
|
+
|
|
341
|
+
# Detect default locale from config
|
|
342
|
+
app_config = Rails.root.join("config", "application.rb")
|
|
343
|
+
if File.exist?(app_config)
|
|
344
|
+
content = File.read(app_config, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue ""
|
|
345
|
+
if (match = content.match(/config\.i18n\.default_locale\s*=\s*[:"'](\w+)/))
|
|
346
|
+
info << "**Default locale:** #{match[1]}"
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Detect primary UI language by sampling flash messages from controllers
|
|
351
|
+
controllers_dir = Rails.root.join("app", "controllers")
|
|
352
|
+
if Dir.exist?(controllers_dir)
|
|
353
|
+
samples = []
|
|
354
|
+
Dir.glob(File.join(controllers_dir, "**", "*.rb")).first(10).each do |path|
|
|
355
|
+
content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
|
|
356
|
+
content.scan(/(?:notice|alert|flash\[:\w+\]):\s*"([^"]+)"/).each { |m| samples << m[0] }
|
|
357
|
+
end
|
|
358
|
+
if samples.any?
|
|
359
|
+
# Check if majority contains non-ASCII (CJK, Korean, etc.)
|
|
360
|
+
non_ascii = samples.count { |s| s.match?(/[^\x00-\x7F]/) }
|
|
361
|
+
ratio = non_ascii.to_f / samples.size
|
|
362
|
+
if ratio > 0.5
|
|
363
|
+
if samples.any? { |s| s.match?(/[\uAC00-\uD7AF]/) }
|
|
364
|
+
info << "**Primary UI language:** Korean (detected from flash messages)"
|
|
365
|
+
elsif samples.any? { |s| s.match?(/[\u4E00-\u9FFF]/) }
|
|
366
|
+
info << "**Primary UI language:** Chinese (detected from flash messages)"
|
|
367
|
+
elsif samples.any? { |s| s.match?(/[\u3040-\u309F\u30A0-\u30FF]/) }
|
|
368
|
+
info << "**Primary UI language:** Japanese (detected from flash messages)"
|
|
369
|
+
else
|
|
370
|
+
info << "**Primary UI language:** non-English (detected from flash messages)"
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
info
|
|
377
|
+
rescue
|
|
378
|
+
[]
|
|
379
|
+
end
|
|
380
|
+
|
|
322
381
|
private_class_method def self.detect_test_pattern
|
|
323
382
|
sections = []
|
|
324
383
|
test_dir = Rails.root.join("test", "controllers")
|
|
@@ -89,16 +89,27 @@ module RailsAiContext
|
|
|
89
89
|
end
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
-
# ViewComponents
|
|
92
|
+
# ViewComponents — grouped by category
|
|
93
93
|
if cached_context[:components].is_a?(Hash) && !cached_context[:components][:error] && cached_context[:components][:components]&.any?
|
|
94
94
|
comp_data = cached_context[:components][:components]
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
|
|
96
|
+
# Group components by inferred category from file path or name
|
|
97
|
+
grouped = comp_data.group_by { |c| categorize_component(c) }
|
|
98
|
+
lines << "## Components (#{comp_data.size})" << ""
|
|
99
|
+
|
|
100
|
+
grouped.sort_by { |cat, _| cat }.each do |category, comps|
|
|
101
|
+
lines << "### #{category}"
|
|
102
|
+
comps.each do |c|
|
|
103
|
+
slots = c[:slots]&.size || 0
|
|
104
|
+
props = c[:props]&.size || 0
|
|
105
|
+
meta = []
|
|
106
|
+
meta << "#{slots} slots" if slots > 0
|
|
107
|
+
meta << "#{props} props" if props > 0
|
|
108
|
+
meta_str = meta.any? ? " (#{meta.join(', ')})" : ""
|
|
109
|
+
lines << "- **#{c[:name]}**#{meta_str}"
|
|
110
|
+
end
|
|
111
|
+
lines << ""
|
|
100
112
|
end
|
|
101
|
-
lines << ""
|
|
102
113
|
end
|
|
103
114
|
|
|
104
115
|
# Accessibility
|
|
@@ -494,6 +505,33 @@ module RailsAiContext
|
|
|
494
505
|
lines
|
|
495
506
|
end
|
|
496
507
|
|
|
508
|
+
def categorize_component(component)
|
|
509
|
+
file = (component[:file] || "").downcase
|
|
510
|
+
name = (component[:name] || "").downcase
|
|
511
|
+
|
|
512
|
+
# Categorize by directory structure first
|
|
513
|
+
if file.include?("/ruby_ui/") || file.include?("/ui/")
|
|
514
|
+
# Sub-categorize UI primitives
|
|
515
|
+
return "UI / Form" if name.match?(/input|select|textarea|form|radio|checkbox/)
|
|
516
|
+
return "UI / Feedback" if name.match?(/alert|toast|dialog|modal/)
|
|
517
|
+
return "UI / Navigation" if name.match?(/breadcrumb|pagination|nav|link|menu|tabs/)
|
|
518
|
+
return "UI / Layout" if name.match?(/card|separator|heading|text|badge/)
|
|
519
|
+
return "UI / Media" if name.match?(/avatar|carousel|image/)
|
|
520
|
+
return "UI / Overlay" if name.match?(/dialog|popover|tooltip|dropdown/)
|
|
521
|
+
return "UI / Primitives"
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
return "Domain / Articles" if name.match?(/article/)
|
|
525
|
+
return "Domain / Comments" if name.match?(/comment/)
|
|
526
|
+
return "Domain / Posts" if name.match?(/post/)
|
|
527
|
+
return "Domain / Users" if name.match?(/user|profile/)
|
|
528
|
+
return "Domain / Likes" if name.match?(/like/)
|
|
529
|
+
return "Domain / Tags" if name.match?(/tag/)
|
|
530
|
+
return "Layout" if name.match?(/layout|flash|pagination|sidebar|login/)
|
|
531
|
+
|
|
532
|
+
"Other"
|
|
533
|
+
end
|
|
534
|
+
|
|
497
535
|
# Safe key extraction — handles both Hash and Array
|
|
498
536
|
def safe_keys(value, limit = nil)
|
|
499
537
|
names = value.is_a?(Hash) ? value.keys : Array(value)
|
|
@@ -146,9 +146,10 @@ module RailsAiContext
|
|
|
146
146
|
private_class_method def self.sensitive_file?(relative_path)
|
|
147
147
|
patterns = RailsAiContext.configuration.sensitive_patterns
|
|
148
148
|
basename = File.basename(relative_path)
|
|
149
|
+
flags = File::FNM_DOTMATCH | File::FNM_CASEFOLD
|
|
149
150
|
patterns.any? do |pattern|
|
|
150
|
-
File.fnmatch(pattern, relative_path,
|
|
151
|
-
File.fnmatch(pattern, basename,
|
|
151
|
+
File.fnmatch(pattern, relative_path, flags) ||
|
|
152
|
+
File.fnmatch(pattern, basename, flags)
|
|
152
153
|
end
|
|
153
154
|
end
|
|
154
155
|
|
|
@@ -141,16 +141,42 @@ module RailsAiContext
|
|
|
141
141
|
private_class_method def self.format_full(env_vars, env_example, dockerfile_vars, external_services, credentials_keys, encrypted_columns, root)
|
|
142
142
|
lines = [ "# Environment Configuration (Full Detail)", "" ]
|
|
143
143
|
|
|
144
|
-
# ENV vars with
|
|
144
|
+
# ENV vars grouped by category with file annotations
|
|
145
145
|
if env_vars.any?
|
|
146
|
-
lines << "## Environment Variables by
|
|
146
|
+
lines << "## Environment Variables by Category"
|
|
147
|
+
|
|
148
|
+
# Build a map: var_name -> { details from all files }
|
|
149
|
+
var_details = {}
|
|
147
150
|
env_vars.sort_by { |file, _| file }.each do |file, vars|
|
|
148
151
|
relative = file.sub("#{root}/", "")
|
|
149
|
-
|
|
152
|
+
vars.each do |v|
|
|
153
|
+
var_details[v[:name]] ||= { files: [], default: nil }
|
|
154
|
+
var_details[v[:name]][:files] << { file: relative, line: v[:line] }
|
|
155
|
+
var_details[v[:name]][:default] ||= v[:default]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Group by category
|
|
160
|
+
categorized = Hash.new { |h, k| h[k] = [] }
|
|
161
|
+
var_details.each do |name, details|
|
|
162
|
+
category = categorize_env_var(name)
|
|
163
|
+
categorized[category] << { name: name, **details }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
category_order = [
|
|
167
|
+
"API Keys & Secrets", "Mail", "Database", "Infrastructure",
|
|
168
|
+
"Monitoring", "Push Notifications", "Other"
|
|
169
|
+
]
|
|
170
|
+
sorted_categories = categorized.keys.sort_by { |k| category_order.index(k) || 99 }
|
|
171
|
+
|
|
172
|
+
sorted_categories.each do |category|
|
|
173
|
+
vars = categorized[category]
|
|
174
|
+
lines << "" << "### #{category}"
|
|
150
175
|
vars.sort_by { |v| v[:name] }.each do |v|
|
|
176
|
+
file_locations = v[:files].map { |f| f[:line] ? "#{f[:file]}:#{f[:line]}" : f[:file] }.uniq
|
|
151
177
|
entry = "- `#{v[:name]}`"
|
|
152
|
-
entry += " (
|
|
153
|
-
entry += "
|
|
178
|
+
entry += " (default: `#{v[:default]}`)" if v[:default]
|
|
179
|
+
entry += " (#{file_locations.join(', ')})"
|
|
154
180
|
lines << entry
|
|
155
181
|
end
|
|
156
182
|
end
|
|
@@ -539,30 +565,30 @@ module RailsAiContext
|
|
|
539
565
|
encrypted
|
|
540
566
|
end
|
|
541
567
|
|
|
568
|
+
private_class_method def self.categorize_env_var(name)
|
|
569
|
+
case name
|
|
570
|
+
when /API_KEY|SECRET|TOKEN/i then "API Keys & Secrets"
|
|
571
|
+
when /MAIL|IMAP|SMTP/i then "Mail"
|
|
572
|
+
when /DATABASE|DB_|REDIS/i then "Database"
|
|
573
|
+
when /OTEL|SENTRY|DATADOG|NEWRELIC|APPSIGNAL/i then "Monitoring"
|
|
574
|
+
when /PUSH|VAPID|FCM/i then "Push Notifications"
|
|
575
|
+
when /PORT|CONCURRENCY|THREADS|WORKERS|TIMEOUT|QUEUE|PIDFILE/i then "Infrastructure"
|
|
576
|
+
else "Other"
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
542
580
|
private_class_method def self.group_env_vars(var_names)
|
|
543
581
|
groups = Hash.new { |h, k| h[k] = [] }
|
|
544
582
|
|
|
545
583
|
var_names.each do |name|
|
|
546
|
-
|
|
547
|
-
when /\ADATABASE_|_DB_|_DATABASE/ then "Database"
|
|
548
|
-
when /\AREDIS_|_REDIS/ then "Redis"
|
|
549
|
-
when /\AAWS_|_S3_|_SES_/ then "AWS"
|
|
550
|
-
when /\ASTRIPE_/ then "Payments (Stripe)"
|
|
551
|
-
when /\ATWILIO_/ then "Twilio"
|
|
552
|
-
when /\ASENDGRID_|_SMTP_|_MAILER_|_MAIL_/ then "Email"
|
|
553
|
-
when /\ASENTRY_|_BUGSNAG_|_ROLLBAR_|_NEWRELIC_|_DD_/ then "Monitoring"
|
|
554
|
-
when /\AOAUTH_|_CLIENT_ID|_CLIENT_SECRET|_API_KEY/ then "API Keys & Auth"
|
|
555
|
-
when /\ARAILS_|_ENV\z|_HOST|_PORT|_URL\z/ then "App Configuration"
|
|
556
|
-
when /\ARECAPTCHA_/ then "Security"
|
|
557
|
-
when /\ASECRET_|_TOKEN|_KEY\z/ then "Secrets"
|
|
558
|
-
else "Other"
|
|
559
|
-
end
|
|
560
|
-
|
|
561
|
-
groups[group] << name
|
|
584
|
+
groups[categorize_env_var(name)] << name
|
|
562
585
|
end
|
|
563
586
|
|
|
564
587
|
# Sort groups: important ones first
|
|
565
|
-
priority =
|
|
588
|
+
priority = [
|
|
589
|
+
"API Keys & Secrets", "Mail", "Database", "Infrastructure",
|
|
590
|
+
"Monitoring", "Push Notifications", "Other"
|
|
591
|
+
]
|
|
566
592
|
groups.sort_by { |k, _| priority.index(k) || 99 }
|
|
567
593
|
end
|
|
568
594
|
|
|
@@ -603,10 +629,11 @@ module RailsAiContext
|
|
|
603
629
|
relative = file.sub("#{root}/", "")
|
|
604
630
|
basename = File.basename(relative)
|
|
605
631
|
patterns = RailsAiContext.configuration.sensitive_patterns
|
|
632
|
+
flags = File::FNM_DOTMATCH | File::FNM_CASEFOLD
|
|
606
633
|
|
|
607
634
|
patterns.any? do |pattern|
|
|
608
|
-
File.fnmatch(pattern, relative,
|
|
609
|
-
File.fnmatch(pattern, basename,
|
|
635
|
+
File.fnmatch(pattern, relative, flags) ||
|
|
636
|
+
File.fnmatch(pattern, basename, flags)
|
|
610
637
|
end
|
|
611
638
|
end
|
|
612
639
|
|
|
@@ -49,22 +49,61 @@ module RailsAiContext
|
|
|
49
49
|
|
|
50
50
|
def build_summary(data)
|
|
51
51
|
parts = []
|
|
52
|
-
parts << "#{data[:framework]}#{version_suffix(data[:version])}" if data[:framework]
|
|
53
|
-
parts << data[:mounting_strategy] if data[:mounting_strategy]
|
|
54
|
-
parts << data[:build_tool] if data[:build_tool]
|
|
52
|
+
parts << "#{data[:framework]}#{version_suffix(data[:version])}" if data[:framework].present?
|
|
53
|
+
parts << data[:mounting_strategy] if data[:mounting_strategy].present?
|
|
54
|
+
parts << data[:build_tool] if data[:build_tool].present?
|
|
55
55
|
|
|
56
56
|
if data[:typescript].is_a?(Hash) && data[:typescript][:enabled]
|
|
57
57
|
parts << "TypeScript"
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
if data[:state_management].is_a?(String) && data[:state_management].present?
|
|
61
|
+
parts << data[:state_management]
|
|
62
|
+
elsif data[:state_management].is_a?(Array) && data[:state_management].any?
|
|
63
|
+
parts << data[:state_management].join(", ")
|
|
64
|
+
end
|
|
61
65
|
|
|
62
66
|
total = total_component_count(data)
|
|
63
67
|
parts << "(#{total} components)" if total > 0
|
|
64
68
|
|
|
69
|
+
# If no JS framework data, try building a Hotwire summary from cached context
|
|
70
|
+
if parts.empty?
|
|
71
|
+
hotwire = build_hotwire_summary
|
|
72
|
+
return hotwire if hotwire
|
|
73
|
+
end
|
|
74
|
+
|
|
65
75
|
parts.any? ? parts.join(" + ") : "No frontend framework detected."
|
|
66
76
|
end
|
|
67
77
|
|
|
78
|
+
def build_hotwire_summary
|
|
79
|
+
stimulus = cached_context[:stimulus]
|
|
80
|
+
gems = cached_context[:gems]
|
|
81
|
+
|
|
82
|
+
notable = gems.is_a?(Hash) && !gems[:error] ? (gems[:notable_gems] || []) : []
|
|
83
|
+
has_turbo = notable.any? { |g| g[:name] == "turbo-rails" }
|
|
84
|
+
has_stimulus = notable.any? { |g| g[:name] == "stimulus-rails" }
|
|
85
|
+
has_importmap = notable.any? { |g| g[:name] == "importmap-rails" }
|
|
86
|
+
has_tailwind = notable.any? { |g| g[:name] == "tailwindcss-rails" }
|
|
87
|
+
|
|
88
|
+
return nil unless has_turbo || has_stimulus
|
|
89
|
+
|
|
90
|
+
parts = []
|
|
91
|
+
parts << "Hotwire (Turbo + Stimulus)" if has_turbo && has_stimulus
|
|
92
|
+
parts << "Hotwire (Turbo)" if has_turbo && !has_stimulus
|
|
93
|
+
parts << "Hotwire (Stimulus)" if !has_turbo && has_stimulus
|
|
94
|
+
|
|
95
|
+
parts << "with importmap-rails" if has_importmap
|
|
96
|
+
|
|
97
|
+
if stimulus.is_a?(Hash) && !stimulus[:error]
|
|
98
|
+
count = stimulus[:total_controllers] || stimulus[:controllers]&.size || 0
|
|
99
|
+
parts << "#{count} Stimulus controllers" if count > 0
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
parts << "Tailwind CSS" if has_tailwind
|
|
103
|
+
|
|
104
|
+
parts.join(", ")
|
|
105
|
+
end
|
|
106
|
+
|
|
68
107
|
def build_standard(data)
|
|
69
108
|
lines = [ "# Frontend Stack", "" ]
|
|
70
109
|
|
|
@@ -90,12 +129,25 @@ module RailsAiContext
|
|
|
90
129
|
lines << "- **Testing:** #{data[:testing_frameworks].join(', ')}"
|
|
91
130
|
end
|
|
92
131
|
|
|
93
|
-
#
|
|
132
|
+
# Hotwire stack — enrich with Stimulus/Turbo data for importmap apps
|
|
133
|
+
enrich_with_hotwire(lines)
|
|
134
|
+
|
|
135
|
+
# Frontend roots with component counts — skip "0 components" for Hotwire apps
|
|
136
|
+
# where Stimulus controllers ARE the components
|
|
137
|
+
has_hotwire = lines.any? { |l| l.include?("Hotwire Stack") }
|
|
94
138
|
if data[:frontend_roots].is_a?(Array) && data[:frontend_roots].any?
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
139
|
+
roots_with_components = data[:frontend_roots].select { |r| (r[:component_count] || 0) > 0 }
|
|
140
|
+
if roots_with_components.any?
|
|
141
|
+
lines << "" << "## Frontend Roots" << ""
|
|
142
|
+
roots_with_components.each do |root|
|
|
143
|
+
lines << "- `#{root[:path]}` — #{root[:component_count]} components"
|
|
144
|
+
end
|
|
145
|
+
elsif !has_hotwire
|
|
146
|
+
# Only show "0 components" for non-Hotwire apps where it's meaningful
|
|
147
|
+
lines << "" << "## Frontend Roots" << ""
|
|
148
|
+
data[:frontend_roots].each do |root|
|
|
149
|
+
lines << "- `#{root[:path]}` — 0 components"
|
|
150
|
+
end
|
|
99
151
|
end
|
|
100
152
|
end
|
|
101
153
|
|
|
@@ -154,6 +206,45 @@ module RailsAiContext
|
|
|
154
206
|
0
|
|
155
207
|
end
|
|
156
208
|
end
|
|
209
|
+
|
|
210
|
+
# For Hotwire/importmap apps, pull Stimulus and Turbo data from context
|
|
211
|
+
def enrich_with_hotwire(lines)
|
|
212
|
+
stimulus = cached_context[:stimulus]
|
|
213
|
+
turbo = cached_context[:turbo]
|
|
214
|
+
gems = cached_context[:gems]
|
|
215
|
+
|
|
216
|
+
# Check if this is a Hotwire app (has turbo-rails or stimulus-rails)
|
|
217
|
+
notable = gems.is_a?(Hash) && !gems[:error] ? (gems[:notable_gems] || []) : []
|
|
218
|
+
has_turbo = notable.any? { |g| g[:name] == "turbo-rails" }
|
|
219
|
+
has_stimulus = notable.any? { |g| g[:name] == "stimulus-rails" }
|
|
220
|
+
has_importmap = notable.any? { |g| g[:name] == "importmap-rails" }
|
|
221
|
+
|
|
222
|
+
return unless has_turbo || has_stimulus
|
|
223
|
+
|
|
224
|
+
lines << ""
|
|
225
|
+
lines << "## Hotwire Stack"
|
|
226
|
+
lines << ""
|
|
227
|
+
lines << "- **Turbo:** turbo-rails (Drive, Frames, Streams)" if has_turbo
|
|
228
|
+
lines << "- **Stimulus:** stimulus-rails" if has_stimulus
|
|
229
|
+
lines << "- **Asset delivery:** importmap-rails (no JS bundler)" if has_importmap
|
|
230
|
+
|
|
231
|
+
if stimulus.is_a?(Hash) && !stimulus[:error]
|
|
232
|
+
count = stimulus[:total_controllers] || stimulus[:controllers]&.size || 0
|
|
233
|
+
if count > 0
|
|
234
|
+
names = (stimulus[:controllers] || []).map { |c| c[:name] || c[:file]&.gsub("_controller.js", "") }.compact.sort
|
|
235
|
+
lines << "- **Stimulus controllers:** #{count} (#{names.first(8).join(', ')}#{count > 8 ? ', ...' : ''})"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
if turbo.is_a?(Hash) && !turbo[:error]
|
|
240
|
+
broadcasts = turbo[:broadcasts]&.size || turbo[:explicit_broadcasts]&.size || 0
|
|
241
|
+
frames = turbo[:frames]&.size || 0
|
|
242
|
+
parts = []
|
|
243
|
+
parts << "#{broadcasts} broadcasts" if broadcasts > 0
|
|
244
|
+
parts << "#{frames} frames" if frames > 0
|
|
245
|
+
lines << "- **Turbo wiring:** #{parts.join(', ')}" if parts.any?
|
|
246
|
+
end
|
|
247
|
+
end
|
|
157
248
|
end
|
|
158
249
|
end
|
|
159
250
|
end
|
|
@@ -123,6 +123,25 @@ module RailsAiContext
|
|
|
123
123
|
lines << "**Structure:** #{map}"
|
|
124
124
|
end
|
|
125
125
|
|
|
126
|
+
# Schema columns — inline from schema introspection
|
|
127
|
+
if data[:table_name]
|
|
128
|
+
schema = cached_context[:schema]
|
|
129
|
+
if schema.is_a?(Hash) && !schema[:error] && schema[:tables]&.key?(data[:table_name])
|
|
130
|
+
table_data = schema[:tables][data[:table_name]]
|
|
131
|
+
cols = table_data[:columns] || []
|
|
132
|
+
if cols.any?
|
|
133
|
+
lines << "" << "## Columns"
|
|
134
|
+
cols.each do |c|
|
|
135
|
+
parts = [ "**#{c[:name]}**", c[:type] ]
|
|
136
|
+
parts << "NOT NULL" if c[:null] == false
|
|
137
|
+
parts << "default: #{c[:default]}" if c[:default] && !c[:default].to_s.empty?
|
|
138
|
+
parts << "array" if c[:array]
|
|
139
|
+
lines << "- #{parts.join(' | ')}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
126
145
|
# Associations
|
|
127
146
|
if data[:associations]&.any?
|
|
128
147
|
lines << "" << "## Associations"
|
|
@@ -212,7 +212,7 @@ module RailsAiContext
|
|
|
212
212
|
prefixed_basename = basename.start_with?("_") ? basename : "_#{basename}"
|
|
213
213
|
unprefixed_basename = basename.delete_prefix("_")
|
|
214
214
|
|
|
215
|
-
extensions = %w[.html.erb .erb .html.haml .haml .html.slim .slim]
|
|
215
|
+
extensions = %w[.html.erb .erb .html.haml .haml .html.slim .slim .rb .json.jbuilder .jbuilder .turbo_stream.erb]
|
|
216
216
|
candidates = []
|
|
217
217
|
|
|
218
218
|
# Try prefixed name with various extensions
|
|
@@ -17,7 +17,7 @@ module RailsAiContext
|
|
|
17
17
|
detail: {
|
|
18
18
|
type: "string",
|
|
19
19
|
enum: %w[summary standard full],
|
|
20
|
-
description: "Detail level. summary: names + counts. standard:
|
|
20
|
+
description: "Detail level. summary: names + counts. standard: targets + values + actions (default). full: everything including outlets, classes, HTML usage."
|
|
21
21
|
},
|
|
22
22
|
limit: {
|
|
23
23
|
type: "integer",
|
|
@@ -73,14 +73,19 @@ module RailsAiContext
|
|
|
73
73
|
|
|
74
74
|
case detail
|
|
75
75
|
when "summary"
|
|
76
|
-
active = controllers.select { |c| (c[:targets] || []).any? || (c[:actions] || []).any? }
|
|
77
|
-
empty = controllers.reject { |c| (c[:targets] || []).any? || (c[:actions] || []).any? }
|
|
76
|
+
active = controllers.select { |c| (c[:targets] || []).any? || (c[:actions] || []).any? || (c[:values].is_a?(Hash) ? c[:values] : {}).any? }
|
|
77
|
+
empty = controllers.reject { |c| (c[:targets] || []).any? || (c[:actions] || []).any? || (c[:values].is_a?(Hash) ? c[:values] : {}).any? }
|
|
78
78
|
|
|
79
79
|
lines = [ "# Stimulus Controllers (#{total})", "" ]
|
|
80
80
|
active.each do |ctrl|
|
|
81
81
|
targets = (ctrl[:targets] || []).size
|
|
82
|
+
values = (ctrl[:values].is_a?(Hash) ? ctrl[:values] : {}).size
|
|
82
83
|
actions = (ctrl[:actions] || []).size
|
|
83
|
-
|
|
84
|
+
parts = []
|
|
85
|
+
parts << "#{targets} targets" if targets > 0
|
|
86
|
+
parts << "#{values} values" if values > 0
|
|
87
|
+
parts << "#{actions} actions" if actions > 0
|
|
88
|
+
lines << "- **#{ctrl[:name]}** — #{parts.join(', ')}"
|
|
84
89
|
end
|
|
85
90
|
if empty.any?
|
|
86
91
|
names = empty.map { |c| c[:name] }.join(", ")
|
|
@@ -90,13 +95,14 @@ module RailsAiContext
|
|
|
90
95
|
text_response(lines.join("\n"))
|
|
91
96
|
|
|
92
97
|
when "standard"
|
|
93
|
-
active = controllers.select { |c| (c[:targets] || []).any? || (c[:actions] || []).any? }
|
|
94
|
-
empty = controllers.reject { |c| (c[:targets] || []).any? || (c[:actions] || []).any? }
|
|
98
|
+
active = controllers.select { |c| (c[:targets] || []).any? || (c[:actions] || []).any? || (c[:values].is_a?(Hash) ? c[:values] : {}).any? }
|
|
99
|
+
empty = controllers.reject { |c| (c[:targets] || []).any? || (c[:actions] || []).any? || (c[:values].is_a?(Hash) ? c[:values] : {}).any? }
|
|
95
100
|
|
|
96
101
|
lines = [ "# Stimulus Controllers (#{total})", "" ]
|
|
97
102
|
active.each do |ctrl|
|
|
98
103
|
lines << "## #{ctrl[:name]}"
|
|
99
104
|
lines << "- Targets: #{(ctrl[:targets] || []).join(', ')}" if ctrl[:targets]&.any?
|
|
105
|
+
lines << "- Values: #{(ctrl[:values].is_a?(Hash) ? ctrl[:values] : {}).map { |k, v| "#{k} (#{v})" }.join(', ')}" if (ctrl[:values].is_a?(Hash) ? ctrl[:values] : {}).any?
|
|
100
106
|
lines << "- Actions: #{(ctrl[:actions] || []).join(', ')}" if ctrl[:actions]&.any?
|
|
101
107
|
if ctrl[:complexity].is_a?(Hash)
|
|
102
108
|
parts = []
|
|
@@ -110,7 +116,7 @@ module RailsAiContext
|
|
|
110
116
|
end
|
|
111
117
|
if empty.any?
|
|
112
118
|
names = empty.map { |c| c[:name] }.join(", ")
|
|
113
|
-
lines << "_Lifecycle only (no targets/actions): #{names}_"
|
|
119
|
+
lines << "_Lifecycle only (no targets/values/actions): #{names}_"
|
|
114
120
|
end
|
|
115
121
|
|
|
116
122
|
# Cross-controller composition
|