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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  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 +18 -10
  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 +10 -19
  18. data/lib/rails_ai_context/serializers/claude_serializer.rb +42 -11
  19. data/lib/rails_ai_context/serializers/context_file_serializer.rb +14 -3
  20. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +1 -1
  21. data/lib/rails_ai_context/serializers/design_system_helper.rb +8 -1
  22. data/lib/rails_ai_context/serializers/opencode_serializer.rb +5 -2
  23. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +165 -64
  24. data/lib/rails_ai_context/server.rb +12 -1
  25. data/lib/rails_ai_context/tools/base_tool.rb +63 -1
  26. data/lib/rails_ai_context/tools/diagnose.rb +436 -0
  27. data/lib/rails_ai_context/tools/generate_test.rb +571 -0
  28. data/lib/rails_ai_context/tools/get_callbacks.rb +27 -4
  29. data/lib/rails_ai_context/tools/get_component_catalog.rb +11 -2
  30. data/lib/rails_ai_context/tools/get_context.rb +70 -8
  31. data/lib/rails_ai_context/tools/get_conventions.rb +59 -0
  32. data/lib/rails_ai_context/tools/get_design_system.rb +45 -7
  33. data/lib/rails_ai_context/tools/get_edit_context.rb +3 -2
  34. data/lib/rails_ai_context/tools/get_env.rb +51 -24
  35. data/lib/rails_ai_context/tools/get_frontend_stack.rb +100 -9
  36. data/lib/rails_ai_context/tools/get_model_details.rb +19 -0
  37. data/lib/rails_ai_context/tools/get_partial_interface.rb +1 -1
  38. data/lib/rails_ai_context/tools/get_stimulus.rb +13 -7
  39. data/lib/rails_ai_context/tools/get_turbo_map.rb +35 -2
  40. data/lib/rails_ai_context/tools/get_view.rb +65 -9
  41. data/lib/rails_ai_context/tools/migration_advisor.rb +4 -0
  42. data/lib/rails_ai_context/tools/onboard.rb +755 -0
  43. data/lib/rails_ai_context/tools/query.rb +4 -2
  44. data/lib/rails_ai_context/tools/read_logs.rb +4 -1
  45. data/lib/rails_ai_context/tools/review_changes.rb +299 -0
  46. data/lib/rails_ai_context/tools/runtime_info.rb +289 -0
  47. data/lib/rails_ai_context/tools/search_code.rb +23 -4
  48. data/lib/rails_ai_context/tools/security_scan.rb +7 -1
  49. data/lib/rails_ai_context/tools/session_context.rb +132 -0
  50. data/lib/rails_ai_context/version.rb +1 -1
  51. 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 the specific action's view ivars (not all views)
98
- ctrl_ivars = extract_ivars_from_text(ctrl_result.content.first[:text])
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
- ivar_check = cross_reference_ivars(ctrl_ivars, view_ivars)
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
- mismatches = false
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
- mismatches = true
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
- mismatches || all.any? ? lines.join("\n") : nil
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
- lines << "" << "---" << "" << schema_result.content.first[:text]
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
- 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
@@ -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: names + targets + actions (default). full: everything including values, outlets, classes."
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
- lines << "- **#{ctrl[:name]}** — #{targets} targets, #{actions} actions"
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