rails-ai-context 4.3.0 → 4.3.2

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -8
  3. data/CLAUDE.md +4 -4
  4. data/CONTRIBUTING.md +1 -1
  5. data/README.md +7 -7
  6. data/SECURITY.md +2 -1
  7. data/docs/GUIDE.md +3 -3
  8. data/lib/generators/rails_ai_context/install/install_generator.rb +2 -2
  9. data/lib/rails_ai_context/configuration.rb +4 -2
  10. data/lib/rails_ai_context/doctor.rb +6 -1
  11. data/lib/rails_ai_context/fingerprinter.rb +24 -0
  12. data/lib/rails_ai_context/introspectors/component_introspector.rb +122 -7
  13. data/lib/rails_ai_context/introspectors/performance_introspector.rb +31 -26
  14. data/lib/rails_ai_context/introspectors/schema_introspector.rb +183 -6
  15. data/lib/rails_ai_context/introspectors/view_introspector.rb +2 -2
  16. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +61 -8
  17. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +13 -22
  18. data/lib/rails_ai_context/serializers/claude_serializer.rb +15 -3
  19. data/lib/rails_ai_context/serializers/context_file_serializer.rb +15 -3
  20. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +3 -3
  21. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +5 -5
  22. data/lib/rails_ai_context/serializers/design_system_helper.rb +8 -1
  23. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +3 -3
  24. data/lib/rails_ai_context/serializers/opencode_serializer.rb +0 -1
  25. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +15 -9
  26. data/lib/rails_ai_context/server.rb +8 -1
  27. data/lib/rails_ai_context/tools/analyze_feature.rb +24 -1
  28. data/lib/rails_ai_context/tools/base_tool.rb +78 -1
  29. data/lib/rails_ai_context/tools/dependency_graph.rb +4 -1
  30. data/lib/rails_ai_context/tools/diagnose.rb +135 -8
  31. data/lib/rails_ai_context/tools/generate_test.rb +87 -7
  32. data/lib/rails_ai_context/tools/get_callbacks.rb +27 -4
  33. data/lib/rails_ai_context/tools/get_component_catalog.rb +11 -2
  34. data/lib/rails_ai_context/tools/get_context.rb +71 -8
  35. data/lib/rails_ai_context/tools/get_conventions.rb +59 -0
  36. data/lib/rails_ai_context/tools/get_design_system.rb +45 -7
  37. data/lib/rails_ai_context/tools/get_edit_context.rb +3 -2
  38. data/lib/rails_ai_context/tools/get_env.rb +51 -24
  39. data/lib/rails_ai_context/tools/get_frontend_stack.rb +100 -9
  40. data/lib/rails_ai_context/tools/get_model_details.rb +20 -0
  41. data/lib/rails_ai_context/tools/get_partial_interface.rb +12 -5
  42. data/lib/rails_ai_context/tools/get_schema.rb +1 -0
  43. data/lib/rails_ai_context/tools/get_stimulus.rb +13 -7
  44. data/lib/rails_ai_context/tools/get_turbo_map.rb +35 -2
  45. data/lib/rails_ai_context/tools/get_view.rb +65 -9
  46. data/lib/rails_ai_context/tools/migration_advisor.rb +10 -3
  47. data/lib/rails_ai_context/tools/onboard.rb +413 -27
  48. data/lib/rails_ai_context/tools/performance_check.rb +45 -28
  49. data/lib/rails_ai_context/tools/query.rb +28 -2
  50. data/lib/rails_ai_context/tools/read_logs.rb +4 -1
  51. data/lib/rails_ai_context/tools/review_changes.rb +27 -17
  52. data/lib/rails_ai_context/tools/runtime_info.rb +289 -0
  53. data/lib/rails_ai_context/tools/search_code.rb +23 -4
  54. data/lib/rails_ai_context/tools/security_scan.rb +7 -1
  55. data/lib/rails_ai_context/tools/session_context.rb +137 -0
  56. data/lib/rails_ai_context/tools/validate.rb +5 -0
  57. data/lib/rails_ai_context/version.rb +1 -1
  58. metadata +6 -4
@@ -118,8 +118,25 @@ module RailsAiContext
118
118
  concern_callbacks = find_concern_callbacks(name, data)
119
119
  if concern_callbacks.any?
120
120
  lines << "" << "## From Concerns"
121
- concern_callbacks.each do |concern_name, cbs|
122
- lines << "- **#{concern_name}:** #{cbs.join(', ')}"
121
+ if detail == "full"
122
+ concern_callbacks.each do |concern_name, info|
123
+ lines << "### #{concern_name}"
124
+ info[:callbacks].each do |cb|
125
+ source = extract_method_source(info[:path], cb[:method_name])
126
+ lines << "- #{cb[:declaration]}"
127
+ if source
128
+ lines << "```ruby"
129
+ lines << source[:code]
130
+ lines << "```"
131
+ lines << ""
132
+ end
133
+ end
134
+ end
135
+ else
136
+ concern_callbacks.each do |concern_name, info|
137
+ declarations = info[:callbacks].map { |cb| cb[:declaration] }
138
+ lines << "- **#{concern_name}:** #{declarations.join(', ')}"
139
+ end
123
140
  end
124
141
  end
125
142
 
@@ -210,6 +227,10 @@ module RailsAiContext
210
227
 
211
228
  private_class_method def self.extract_callback_source(model_name, method_name)
212
229
  path = Rails.root.join("app", "models", "#{model_name.underscore}.rb")
230
+ extract_method_source(path, method_name)
231
+ end
232
+
233
+ private_class_method def self.extract_method_source(path, method_name)
213
234
  return nil unless File.exist?(path)
214
235
  return nil if File.size(path) > RailsAiContext.configuration.max_file_size
215
236
 
@@ -263,11 +284,13 @@ module RailsAiContext
263
284
 
264
285
  source.each_line do |line|
265
286
  if (match = line.match(/\A\s*(before_\w+|after_\w+|around_\w+)\s+[: ]*(\w+)/))
266
- callbacks << "#{match[1]} :#{match[2]}"
287
+ callbacks << { declaration: "#{match[1]} :#{match[2]}", method_name: match[2] }
267
288
  end
268
289
  end
269
290
 
270
- concern_callbacks[concern_name] = callbacks if callbacks.any?
291
+ if callbacks.any?
292
+ concern_callbacks[concern_name] = { callbacks: callbacks, path: concern_path }
293
+ end
271
294
  end
272
295
 
273
296
  concern_callbacks
@@ -53,7 +53,15 @@ module RailsAiContext
53
53
 
54
54
  text_response(render_single(found, detail))
55
55
  else
56
- return text_response("No components found in app/components/.") if components.empty?
56
+ if components.empty?
57
+ return text_response(
58
+ "No components found in app/components/.\n\n" \
59
+ "This app may use ERB partials instead of ViewComponent/Phlex. Try:\n" \
60
+ "- `rails_get_partial_interface(partial:\"shared/partial_name\")` — partial locals contract + usage\n" \
61
+ "- `rails_get_view(controller:\"name\")` — view templates with partial/Stimulus references\n" \
62
+ "- `rails_get_design_system` — UI component patterns extracted from views"
63
+ )
64
+ end
57
65
  text_response(render_catalog(components, data[:summary], detail))
58
66
  end
59
67
  end
@@ -102,7 +110,8 @@ module RailsAiContext
102
110
  lines << "**Props:**"
103
111
  comp[:props].each do |prop|
104
112
  default = prop[:default] ? " (default: #{prop[:default]})" : ""
105
- lines << " - `#{prop[:name]}`#{default}"
113
+ values = prop[:values]&.any? ? " -- values: #{prop[:values].join(', ')}" : ""
114
+ lines << " - `#{prop[:name]}`#{default}#{values}"
106
115
  end
107
116
  end
108
117
 
@@ -37,6 +37,7 @@ module RailsAiContext
37
37
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
38
38
 
39
39
  def self.call(controller: nil, action: nil, model: nil, feature: nil, include: nil, server_context: nil)
40
+ set_call_params(controller: controller, action: action, model: model, feature: feature)
40
41
  result = if controller && action
41
42
  controller_action_context(controller, action)
42
43
  elsif controller
@@ -94,10 +95,18 @@ module RailsAiContext
94
95
  lines << view_text
95
96
  end
96
97
 
97
- # Cross-reference: controller ivars vs the specific action's view ivars (not all views)
98
- ctrl_ivars = extract_ivars_from_text(ctrl_result.content.first[:text])
98
+ # Cross-reference: controller ivars vs view ivars
99
+ # Also check templates rendered by the action (e.g., create renders :new on failure)
100
+ ctrl_text = ctrl_result.content.first[:text]
101
+ ctrl_ivars = extract_ivars_from_text(ctrl_text)
99
102
  view_ivars = extract_ivars_from_view_text(view_text, action: action_name)
100
- ivar_check = cross_reference_ivars(ctrl_ivars, view_ivars)
103
+ # Detect "render :other_template" and include those templates' ivars too
104
+ rendered = ctrl_text.scan(/render\s+:(\w+)/).flatten.uniq
105
+ other_templates = rendered.reject { |t| t == action_name }
106
+ other_templates.each do |tmpl|
107
+ view_ivars.merge(extract_ivars_from_view_text(view_text, action: tmpl))
108
+ end
109
+ ivar_check = cross_reference_ivars(ctrl_ivars, view_ivars, rendered_templates: other_templates)
101
110
  lines << "" << ivar_check if ivar_check
102
111
 
103
112
  text_response(lines.join("\n"))
@@ -140,13 +149,13 @@ module RailsAiContext
140
149
  ivars
141
150
  end
142
151
 
143
- private_class_method def self.cross_reference_ivars(ctrl_ivars, view_ivars)
152
+ private_class_method def self.cross_reference_ivars(ctrl_ivars, view_ivars, rendered_templates: [])
144
153
  return nil if ctrl_ivars.empty? && view_ivars.empty?
145
154
 
146
155
  lines = [ "## Instance Variable Cross-Check" ]
147
156
  all = (ctrl_ivars | view_ivars).sort
148
157
 
149
- mismatches = false
158
+ missing_ivars = []
150
159
  all.each do |ivar|
151
160
  in_ctrl = ctrl_ivars.include?(ivar)
152
161
  in_view = view_ivars.include?(ivar)
@@ -154,13 +163,21 @@ module RailsAiContext
154
163
  lines << "- \u2713 @#{ivar} — set in controller, used in view"
155
164
  elsif in_view && !in_ctrl
156
165
  lines << "- \u2717 @#{ivar} — used in view but NOT set in controller"
157
- mismatches = true
166
+ missing_ivars << ivar
158
167
  elsif in_ctrl && !in_view
159
168
  lines << "- \u26A0 @#{ivar} — set in controller but not used in view"
160
169
  end
161
170
  end
162
171
 
163
- mismatches || all.any? ? lines.join("\n") : nil
172
+ # If there are missing ivars AND this action renders another template,
173
+ # add a note explaining why — the other action likely sets them
174
+ if missing_ivars.any? && rendered_templates.any?
175
+ templates = rendered_templates.map { |t| "`#{t}`" }.join(", ")
176
+ lines << ""
177
+ lines << "_Note: This action renders #{templates} on failure — those ivars are likely set in the corresponding action(s)._"
178
+ end
179
+
180
+ (missing_ivars.any? || all.any?) ? lines.join("\n") : nil
164
181
  end
165
182
 
166
183
  private_class_method def self.controller_context(controller_name)
@@ -215,7 +232,11 @@ module RailsAiContext
215
232
 
216
233
  if key && models[key][:table_name]
217
234
  schema_result = GetSchema.call(table: models[key][:table_name])
218
- lines << "" << "---" << "" << schema_result.content.first[:text]
235
+ schema_text = schema_result.content.first[:text]
236
+ # Only append schema if it actually has useful data (not "not found")
237
+ unless schema_text.include?("not found") || schema_text.include?("Available:")
238
+ lines << "" << "---" << "" << schema_text
239
+ end
219
240
  end
220
241
 
221
242
  # Tests for this model
@@ -271,16 +292,58 @@ module RailsAiContext
271
292
  ctx = begin; cached_context; rescue; nil; end
272
293
  if ctx
273
294
  models = ctx[:models] || {}
295
+ matched_tables = Set.new
296
+
274
297
  models.each_key do |model_name|
275
298
  next unless model_name.downcase.include?(feature_name.downcase)
276
299
  table_name = models[model_name][:table_name]
277
300
  next unless table_name
301
+ matched_tables << table_name
278
302
  schema_result = GetSchema.call(table: table_name)
279
303
  schema_text = schema_result.content.first[:text]
280
304
  unless schema_text.include?("not found")
281
305
  lines << "" << "---" << "" << schema_text
282
306
  end
283
307
  end
308
+
309
+ # Also include schema for related models (associated tables) if the
310
+ # primary model was found but the feature analysis missed controllers/services
311
+ analyze_text = analyze_result.content.first[:text]
312
+ has_controllers = analyze_text.include?("## Controllers")
313
+ unless has_controllers
314
+ # Check if any controllers or services reference this feature by name
315
+ controllers = ctx[:controllers]
316
+ if controllers.is_a?(Hash) && !controllers[:error]
317
+ related_ctrls = (controllers[:controllers] || []).select do |c|
318
+ c_name = c[:name] || ""
319
+ c_name.downcase.include?(feature_name.downcase) ||
320
+ c_name.downcase.include?(feature_name.singularize.downcase) ||
321
+ c_name.downcase.include?(feature_name.pluralize.downcase)
322
+ end
323
+ if related_ctrls.any?
324
+ lines << "" << "## Related Controllers (by name)"
325
+ related_ctrls.each do |c|
326
+ actions = (c[:actions] || []).map { |a| a.is_a?(Hash) ? a[:name] : a }.compact
327
+ lines << "- **#{c[:name]}** — #{actions.join(', ')}"
328
+ end
329
+ end
330
+ end
331
+
332
+ # Check services
333
+ services = ctx[:services] || ctx[:service_objects]
334
+ if services.is_a?(Hash) && !services[:error]
335
+ service_list = services[:services] || []
336
+ related_svcs = service_list.select do |s|
337
+ s_name = (s[:name] || s[:file] || "").to_s
338
+ s_name.downcase.include?(feature_name.downcase) ||
339
+ s_name.downcase.include?(feature_name.singularize.downcase)
340
+ end
341
+ if related_svcs.any?
342
+ lines << "" << "## Related Services (by name)"
343
+ related_svcs.each { |s| lines << "- `#{s[:file] || s[:name]}`" }
344
+ end
345
+ end
346
+ end
284
347
  end
285
348
 
286
349
  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
- lines << "## Components" << ""
96
- comp_data.first(15).each do |c|
97
- slots = c[:slots]&.size || 0
98
- slot_info = slots > 0 ? " (#{slots} slots)" : ""
99
- lines << "- **#{c[:name]}**#{slot_info}"
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, File::FNM_DOTMATCH) ||
151
- File.fnmatch(pattern, basename, File::FNM_DOTMATCH)
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 per-file locations
144
+ # ENV vars grouped by category with file annotations
145
145
  if env_vars.any?
146
- lines << "## Environment Variables by File"
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
- lines << "" << "### `#{relative}`"
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 += " (fetch, default: `#{v[:default]}`)" if v[:default]
153
- entry += " — line #{v[:line]}" if v[:line]
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
- group = case name
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 = %w[App\ Configuration Database Redis AWS Payments\ (Stripe) Email API\ Keys\ &\ Auth Monitoring Security Secrets Other]
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, File::FNM_DOTMATCH) ||
609
- File.fnmatch(pattern, basename, File::FNM_DOTMATCH)
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
- parts << data[:state_management] if data[:state_management]
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
- # Frontend roots with component counts
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
- lines << "" << "## Frontend Roots" << ""
96
- data[:frontend_roots].each do |root|
97
- count = root[:component_count] || 0
98
- lines << "- `#{root[:path]}` — #{count} components"
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
@@ -33,6 +33,7 @@ module RailsAiContext
33
33
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
34
34
 
35
35
  def self.call(model: nil, detail: "standard", limit: nil, offset: 0, server_context: nil)
36
+ set_call_params(model: model, detail: detail)
36
37
  models = cached_context[:models]
37
38
  return text_response("Model introspection not available. Add :models to introspectors.") unless models
38
39
  return text_response("Model introspection failed: #{models[:error]}") if models[:error]
@@ -123,6 +124,25 @@ module RailsAiContext
123
124
  lines << "**Structure:** #{map}"
124
125
  end
125
126
 
127
+ # Schema columns — inline from schema introspection
128
+ if data[:table_name]
129
+ schema = cached_context[:schema]
130
+ if schema.is_a?(Hash) && !schema[:error] && schema[:tables]&.key?(data[:table_name])
131
+ table_data = schema[:tables][data[:table_name]]
132
+ cols = table_data[:columns] || []
133
+ if cols.any?
134
+ lines << "" << "## Columns"
135
+ cols.each do |c|
136
+ parts = [ "**#{c[:name]}**", c[:type] ]
137
+ parts << "NOT NULL" if c[:null] == false
138
+ parts << "default: #{c[:default]}" if c[:default] && !c[:default].to_s.empty?
139
+ parts << "array" if c[:array]
140
+ lines << "- #{parts.join(' | ')}"
141
+ end
142
+ end
143
+ end
144
+ end
145
+
126
146
  # Associations
127
147
  if data[:associations]&.any?
128
148
  lines << "" << "## Associations"