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
@@ -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
@@ -332,18 +332,24 @@ module RailsAiContext
332
332
  private_class_method def self.find_render_sites(views_dir, partial, root)
333
333
  sites = []
334
334
  # Build search names: the partial can be referenced multiple ways
335
+ # Normalize: strip underscore prefix from basename and extensions
335
336
  parts = partial.split("/")
336
337
  basename = parts.last.delete_prefix("_").sub(/\..*\z/, "")
337
338
  dir_prefix = parts[0...-1].join("/")
338
339
 
340
+ # Build the canonical render name (how Rails references partials in render calls)
341
+ # "shared/_status_badge.html.erb" → "shared/status_badge"
342
+ # "_status_badge" → "status_badge"
343
+ canonical = (dir_prefix.empty? ? basename : "#{dir_prefix}/#{basename}")
344
+
339
345
  # Possible render references:
340
346
  # render "shared/status_badge"
341
347
  # render partial: "shared/status_badge"
342
348
  # render "status_badge" (from same directory)
343
349
  search_patterns = [
344
- partial.delete_prefix("_").sub(/\..*\z/, ""), # shared/status_badge
345
- basename # status_badge
346
- ]
350
+ canonical, # shared/status_badge
351
+ basename # status_badge
352
+ ].uniq
347
353
 
348
354
  view_files = Dir.glob(File.join(views_dir, "**", "*.{erb,haml,slim}")).sort
349
355
 
@@ -357,7 +363,8 @@ module RailsAiContext
357
363
  content.each_line.with_index(1) do |line, line_num|
358
364
  search_patterns.each do |search_name|
359
365
  # Match render "partial_name" or render partial: "partial_name"
360
- next unless line.match?(/render\s.*["']#{Regexp.escape(search_name)}["']/)
366
+ # Allow content before search_name (e.g. "shared/status_badge" matches "status_badge")
367
+ next unless line.match?(/render\s.*["'][^"']*#{Regexp.escape(search_name)}["']/)
361
368
 
362
369
  # For short basename matches, verify directory context
363
370
  if search_name == basename && dir_prefix.length > 0
@@ -43,6 +43,7 @@ module RailsAiContext
43
43
  )
44
44
 
45
45
  def self.call(table: nil, detail: "standard", limit: nil, offset: 0, format: "markdown", server_context: nil)
46
+ set_call_params(table: table, detail: detail)
46
47
  schema = cached_context[:schema]
47
48
  return text_response("Schema introspection not available. Add :schema to introspectors.") unless schema
48
49
  return text_response("Schema introspection not available: #{schema[:error]}") if schema[:error]
@@ -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
@@ -105,7 +105,11 @@ module RailsAiContext
105
105
 
106
106
  private_class_method def self.format_summary(model_broadcasts, rb_broadcasts, view_subscriptions, view_frames, warnings, filter_label: nil)
107
107
  total_broadcasts = model_broadcasts.size + rb_broadcasts.size
108
+ turbo_data = cached_context[:turbo]
109
+ turbo_stream_response_count = turbo_data.is_a?(Hash) && !turbo_data[:error] ? turbo_data[:turbo_stream_responses]&.size.to_i : 0
110
+
108
111
  lines = [ "# Turbo Map", "" ]
112
+ lines << "- **Turbo Stream responses:** #{turbo_stream_response_count} (controller `.turbo_stream.erb` templates)" if turbo_stream_response_count > 0
109
113
  lines << "- **Model broadcasts:** #{model_broadcasts.size} (via `broadcasts`, `broadcasts_to`, etc.)"
110
114
  lines << "- **Explicit broadcasts:** #{rb_broadcasts.size} (via `broadcast_*_to` calls in .rb files)"
111
115
  lines << "- **Stream subscriptions:** #{view_subscriptions.size} (`turbo_stream_from` in views)"
@@ -195,7 +199,9 @@ module RailsAiContext
195
199
  lines << ""
196
200
  end
197
201
 
198
- if model_broadcasts.empty? && rb_broadcasts.empty? && view_subscriptions.empty? && view_frames.empty?
202
+ has_turbo_stream_responses = turbo_data.is_a?(Hash) && turbo_data[:turbo_stream_responses]&.any?
203
+
204
+ if model_broadcasts.empty? && rb_broadcasts.empty? && view_subscriptions.empty? && view_frames.empty? && !has_turbo_stream_responses
199
205
  if filter_label
200
206
  lines << "_No Turbo usage matching #{filter_label}. Try without filter to see all Turbo Streams and Frames._"
201
207
  else
@@ -211,6 +217,31 @@ module RailsAiContext
211
217
  private_class_method def self.format_full(model_broadcasts, rb_broadcasts, view_subscriptions, view_frames, warnings, filter_label: nil)
212
218
  lines = [ "# Turbo Map (Full Detail)", "" ]
213
219
 
220
+ # Turbo Drive Configuration & Stream Responses
221
+ turbo_data = cached_context[:turbo]
222
+ if turbo_data.is_a?(Hash) && !turbo_data[:error]
223
+ drive_parts = []
224
+ drive_parts << "morph: #{turbo_data[:morph_meta] ? 'yes' : 'no'}" unless turbo_data[:morph_meta].nil?
225
+ drive_parts << "permanent elements: #{turbo_data[:permanent_elements].size}" if turbo_data[:permanent_elements]&.any?
226
+ if turbo_data[:turbo_drive_settings].is_a?(Hash) && turbo_data[:turbo_drive_settings].any?
227
+ turbo_data[:turbo_drive_settings].each { |k, v| drive_parts << "#{k}: #{v}" }
228
+ end
229
+ if drive_parts.any?
230
+ lines << "## Turbo Drive Configuration"
231
+ drive_parts.each { |p| lines << "- #{p}" }
232
+ lines << ""
233
+ end
234
+
235
+ # Turbo Stream responses
236
+ if turbo_data[:turbo_stream_responses]&.any?
237
+ lines << "## Turbo Stream Responses (#{turbo_data[:turbo_stream_responses].size})"
238
+ turbo_data[:turbo_stream_responses].each do |resp|
239
+ lines << "- `#{resp}`"
240
+ end
241
+ lines << ""
242
+ end
243
+ end
244
+
214
245
  # Model broadcasts with full context
215
246
  if model_broadcasts.any?
216
247
  lines << "## Model Broadcasts (#{model_broadcasts.size})"
@@ -289,7 +320,9 @@ module RailsAiContext
289
320
  lines << ""
290
321
  end
291
322
 
292
- if model_broadcasts.empty? && rb_broadcasts.empty? && view_subscriptions.empty? && view_frames.empty?
323
+ has_turbo_stream_responses = turbo_data.is_a?(Hash) && turbo_data[:turbo_stream_responses]&.any?
324
+
325
+ if model_broadcasts.empty? && rb_broadcasts.empty? && view_subscriptions.empty? && view_frames.empty? && !has_turbo_stream_responses
293
326
  if filter_label
294
327
  lines << "_No Turbo usage matching #{filter_label}. Try without filter to see all Turbo Streams and Frames._"
295
328
  else
@@ -87,7 +87,9 @@ module RailsAiContext
87
87
  ctrl_templates.sort.each do |name, meta|
88
88
  parts = meta[:partials]&.any? ? " renders: #{meta[:partials].join(', ')}" : ""
89
89
  stim = meta[:stimulus]&.any? ? " stimulus: #{meta[:stimulus].join(', ')}" : ""
90
- lines << "- #{name} (#{meta[:lines]} lines)#{parts}#{stim}"
90
+ comps = meta[:components]&.any? ? " components: #{meta[:components].join(', ')}" : ""
91
+ phlex_tag = meta[:phlex] ? " [phlex]" : ""
92
+ lines << "- #{name} (#{meta[:lines]} lines#{phlex_tag})#{parts}#{comps}#{stim}"
91
93
  end
92
94
  ctrl_partials.sort.each do |name, meta|
93
95
  lines << "- #{name} (#{meta[:lines]} lines)"
@@ -118,13 +120,29 @@ module RailsAiContext
118
120
  lines << "## #{ctrl}/" unless controller && all_dirs.size == 1
119
121
  ctrl_templates.sort.each do |name, meta|
120
122
  detail_parts = []
121
- detail_parts << "renders: #{meta[:partials].join(', ')}" if meta[:partials]&.any?
122
- detail_parts << "stimulus: #{meta[:stimulus].join(', ')}" if meta[:stimulus]&.any?
123
123
  extra = extract_view_metadata(name)
124
- detail_parts << "ivars: #{extra[:ivars].join(', ')}" if extra[:ivars]&.any?
125
- detail_parts << "turbo: #{extra[:turbo].join(', ')}" if extra[:turbo]&.any?
124
+
125
+ if meta[:phlex]
126
+ # Phlex views: show components, helpers, stimulus, ivars
127
+ # Prefer introspector-level data, fall back to extract_view_metadata
128
+ components = meta[:components]&.any? ? meta[:components] : extra[:components]
129
+ helpers = meta[:helpers]&.any? ? meta[:helpers] : extra[:helpers]
130
+ detail_parts << "ivars: #{extra[:ivars].join(', ')}" if extra[:ivars]&.any?
131
+ detail_parts << "components: #{components.join(', ')}" if components&.any?
132
+ detail_parts << "helpers: #{helpers.join(', ')}" if helpers&.any?
133
+ detail_parts << "stimulus: #{meta[:stimulus].join(', ')}" if meta[:stimulus]&.any?
134
+ detail_parts << "turbo: #{extra[:turbo].join(', ')}" if extra[:turbo]&.any?
135
+ else
136
+ # ERB/Haml/Slim views: existing behavior
137
+ detail_parts << "renders: #{meta[:partials].join(', ')}" if meta[:partials]&.any?
138
+ detail_parts << "stimulus: #{meta[:stimulus].join(', ')}" if meta[:stimulus]&.any?
139
+ detail_parts << "ivars: #{extra[:ivars].join(', ')}" if extra[:ivars]&.any?
140
+ detail_parts << "turbo: #{extra[:turbo].join(', ')}" if extra[:turbo]&.any?
141
+ end
142
+
143
+ phlex_tag = meta[:phlex] ? " [phlex]" : ""
126
144
  details = detail_parts.any? ? " — #{detail_parts.join(' | ')}" : ""
127
- lines << "- **#{name}** (#{meta[:lines]} lines)#{details}"
145
+ lines << "- **#{name}** (#{meta[:lines]} lines#{phlex_tag})#{details}"
128
146
  end
129
147
  ctrl_partials.sort.each do |name, meta|
130
148
  fields = meta[:fields]&.any? ? " fields: #{meta[:fields].join(', ')}" : ""
@@ -269,7 +287,7 @@ module RailsAiContext
269
287
  # Extract instance variables and Turbo wiring from a view template
270
288
  private_class_method def self.extract_view_metadata(relative_path)
271
289
  content = read_view_content(relative_path)
272
- return { ivars: [], turbo: [] } if content.nil? || content.include?("(file not found)")
290
+ return { ivars: [], turbo: [], components: [], helpers: [] } if content.nil? || content.include?("(file not found)")
273
291
 
274
292
  # Instance variables used in template
275
293
  ivars = content.scan(/@(\w+)/).flatten.uniq.reject { |v| %w[output_buffer virtual_path _request].include?(v) }.sort
@@ -284,9 +302,47 @@ module RailsAiContext
284
302
  turbo << "stream:#{val}" unless val.start_with?('"') || val.start_with?("'") || turbo.any? { |t| t.include?(val) }
285
303
  end
286
304
 
287
- { ivars: ivars, turbo: turbo.uniq }
305
+ result = { ivars: ivars, turbo: turbo.uniq }
306
+
307
+ # For Phlex views (.rb), extract component renders and helper calls
308
+ if relative_path.end_with?(".rb") && phlex_view_content?(content)
309
+ result[:components] = extract_phlex_components(content)
310
+ result[:helpers] = extract_phlex_helpers(content)
311
+ end
312
+
313
+ result
288
314
  rescue
289
- { ivars: [], turbo: [] }
315
+ { ivars: [], turbo: [], components: [], helpers: [] }
316
+ end
317
+
318
+ # Detect if content is a Phlex view class
319
+ private_class_method def self.phlex_view_content?(content)
320
+ content.match?(/class\s+\S+\s*<\s*\S+/) && content.match?(/def\s+view_template\b/)
321
+ end
322
+
323
+ # Extract component render calls from Phlex Ruby DSL
324
+ private_class_method def self.extract_phlex_components(content)
325
+ components = Set.new
326
+ content.scan(/render[\s(]+([A-Z]\w+(?:::\w+)*)\.new/).each do |match|
327
+ components << match[0]
328
+ end
329
+ components.to_a.sort
330
+ end
331
+
332
+ # Extract helper method calls from Phlex views
333
+ PHLEX_HELPERS = %w[
334
+ link_to image_tag content_for button_to form_with form_for
335
+ content_tag tag number_to_currency number_to_human
336
+ time_ago_in_words distance_of_time_in_words
337
+ truncate pluralize raw sanitize dom_id
338
+ ].freeze
339
+
340
+ private_class_method def self.extract_phlex_helpers(content)
341
+ helpers = []
342
+ PHLEX_HELPERS.each do |method|
343
+ helpers << method if content.match?(/\b#{method}\b/)
344
+ end
345
+ helpers
290
346
  end
291
347
 
292
348
  # Scan templates that render a partial to extract locals keys
@@ -108,7 +108,8 @@ module RailsAiContext
108
108
  private
109
109
 
110
110
  def migration_class_name(action, table, column = nil)
111
- parts = [ action.camelize, column&.camelize, "To", table.camelize ].compact
111
+ preposition = action == "remove" ? "From" : "To"
112
+ parts = [ action.camelize, column&.camelize, preposition, table.camelize ].compact
112
113
  parts.join
113
114
  end
114
115
 
@@ -130,6 +131,8 @@ module RailsAiContext
130
131
  opts = options ? ", #{options}" : ""
131
132
  class_name = migration_class_name("add", table, column)
132
133
 
134
+ lines << "**Run:** `bin/rails generate migration #{class_name} #{column}:#{type}`"
135
+ lines << ""
133
136
  lines << "```ruby"
134
137
  lines << "# rails generate migration #{class_name} #{column}:#{type}"
135
138
  lines << "class #{class_name} < ActiveRecord::Migration[#{rails_version}]"
@@ -165,6 +168,8 @@ module RailsAiContext
165
168
  # Check if column is referenced
166
169
  col_type = find_column_type(table, column, schema) || type || "string"
167
170
 
171
+ lines << "**Run:** `bin/rails generate migration #{class_name} #{column}:#{col_type}`"
172
+ lines << ""
168
173
  lines << "**Warning:** `remove_column` is irreversible without specifying the column type."
169
174
  lines << ""
170
175
  lines << "```ruby"
@@ -282,6 +287,9 @@ module RailsAiContext
282
287
 
283
288
  opts = options ? ", #{options}" : ""
284
289
 
290
+ # Detect original column type from schema for a reversible down method
291
+ original_type = find_column_type(table, column, cached_context[:schema]) || "string"
292
+
285
293
  lines << "**Warning:** Changing column type may cause data loss if types are incompatible."
286
294
  lines << ""
287
295
  lines << "```ruby"
@@ -291,8 +299,7 @@ module RailsAiContext
291
299
  lines << " end"
292
300
  lines << ""
293
301
  lines << " def down"
294
- lines << " # Specify the original type here"
295
- lines << " change_column :#{table}, :#{column}, :original_type"
302
+ lines << " change_column :#{table}, :#{column}, :#{original_type}"
296
303
  lines << " end"
297
304
  lines << "end"
298
305
  lines << "```"