rails-ai-context 4.3.2 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +127 -53
  3. data/CLAUDE.md +3 -1
  4. data/README.md +268 -197
  5. data/demo-trace.gif +0 -0
  6. data/demo-trace.tape +21 -0
  7. data/demo.gif +0 -0
  8. data/demo.tape +33 -0
  9. data/docs/GUIDE.md +9 -9
  10. data/lib/generators/rails_ai_context/install/install_generator.rb +2 -1
  11. data/lib/rails_ai_context/cli/tool_runner.rb +1 -1
  12. data/lib/rails_ai_context/configuration.rb +25 -1
  13. data/lib/rails_ai_context/doctor.rb +4 -2
  14. data/lib/rails_ai_context/fingerprinter.rb +6 -1
  15. data/lib/rails_ai_context/introspectors/accessibility_introspector.rb +52 -1
  16. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +10 -2
  17. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +22 -2
  18. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +50 -4
  19. data/lib/rails_ai_context/introspectors/api_introspector.rb +41 -5
  20. data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +10 -4
  21. data/lib/rails_ai_context/introspectors/auth_introspector.rb +62 -7
  22. data/lib/rails_ai_context/introspectors/component_introspector.rb +6 -0
  23. data/lib/rails_ai_context/introspectors/config_introspector.rb +59 -9
  24. data/lib/rails_ai_context/introspectors/controller_introspector.rb +45 -13
  25. data/lib/rails_ai_context/introspectors/convention_detector.rb +25 -2
  26. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +58 -4
  27. data/lib/rails_ai_context/introspectors/design_token_introspector.rb +27 -5
  28. data/lib/rails_ai_context/introspectors/devops_introspector.rb +15 -8
  29. data/lib/rails_ai_context/introspectors/engine_introspector.rb +12 -3
  30. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +36 -1
  31. data/lib/rails_ai_context/introspectors/gem_introspector.rb +47 -1
  32. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +49 -3
  33. data/lib/rails_ai_context/introspectors/job_introspector.rb +48 -5
  34. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +24 -3
  35. data/lib/rails_ai_context/introspectors/migration_introspector.rb +4 -1
  36. data/lib/rails_ai_context/introspectors/model_introspector.rb +108 -11
  37. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +57 -12
  38. data/lib/rails_ai_context/introspectors/performance_introspector.rb +34 -9
  39. data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +12 -2
  40. data/lib/rails_ai_context/introspectors/route_introspector.rb +25 -8
  41. data/lib/rails_ai_context/introspectors/schema_introspector.rb +45 -7
  42. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +5 -2
  43. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +59 -6
  44. data/lib/rails_ai_context/introspectors/test_introspector.rb +50 -5
  45. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +44 -13
  46. data/lib/rails_ai_context/introspectors/view_introspector.rb +46 -7
  47. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +25 -7
  48. data/lib/rails_ai_context/resources.rb +1 -1
  49. data/lib/rails_ai_context/server.rb +6 -3
  50. data/lib/rails_ai_context/tasks/rails_ai_context.rake +8 -4
  51. data/lib/rails_ai_context/tools/analyze_feature.rb +66 -19
  52. data/lib/rails_ai_context/tools/base_tool.rb +1 -1
  53. data/lib/rails_ai_context/tools/diagnose.rb +4 -2
  54. data/lib/rails_ai_context/tools/get_callbacks.rb +4 -2
  55. data/lib/rails_ai_context/tools/get_concern.rb +12 -6
  56. data/lib/rails_ai_context/tools/get_controllers.rb +10 -5
  57. data/lib/rails_ai_context/tools/get_conventions.rb +4 -2
  58. data/lib/rails_ai_context/tools/get_design_system.rb +2 -1
  59. data/lib/rails_ai_context/tools/get_env.rb +8 -4
  60. data/lib/rails_ai_context/tools/get_helper_methods.rb +6 -3
  61. data/lib/rails_ai_context/tools/get_job_pattern.rb +2 -1
  62. data/lib/rails_ai_context/tools/get_model_details.rb +10 -5
  63. data/lib/rails_ai_context/tools/get_partial_interface.rb +14 -7
  64. data/lib/rails_ai_context/tools/get_schema.rb +2 -1
  65. data/lib/rails_ai_context/tools/get_service_pattern.rb +2 -1
  66. data/lib/rails_ai_context/tools/get_stimulus.rb +2 -1
  67. data/lib/rails_ai_context/tools/get_test_info.rb +4 -2
  68. data/lib/rails_ai_context/tools/get_turbo_map.rb +22 -11
  69. data/lib/rails_ai_context/tools/get_view.rb +6 -3
  70. data/lib/rails_ai_context/tools/migration_advisor.rb +2 -1
  71. data/lib/rails_ai_context/tools/onboard.rb +2 -1
  72. data/lib/rails_ai_context/tools/performance_check.rb +2 -1
  73. data/lib/rails_ai_context/tools/query.rb +5 -1
  74. data/lib/rails_ai_context/tools/read_logs.rb +3 -0
  75. data/lib/rails_ai_context/tools/runtime_info.rb +10 -5
  76. data/lib/rails_ai_context/tools/search_code.rb +8 -4
  77. data/lib/rails_ai_context/tools/search_docs.rb +2 -1
  78. data/lib/rails_ai_context/tools/session_context.rb +2 -1
  79. data/lib/rails_ai_context/tools/validate.rb +16 -8
  80. data/lib/rails_ai_context/version.rb +1 -1
  81. metadata +5 -1
@@ -99,11 +99,20 @@ module RailsAiContext
99
99
  next if engine.name == "RailsAiContext::Engine"
100
100
  next if engine.name.start_with?("Rails::", "ActionPack::", "ActionView::", "ActiveModel::")
101
101
 
102
- { name: engine.name, root: engine.root.to_s.sub("#{Gem.dir}/gems/", "") }
103
- rescue
102
+ entry = { name: engine.name, root: engine.root.to_s.sub("#{Gem.dir}/gems/", "") }
103
+ # Count routes and models inside the engine
104
+ if engine.respond_to?(:routes) && engine.routes.respond_to?(:routes)
105
+ entry[:route_count] = engine.routes.routes.size rescue nil
106
+ end
107
+ models_dir = File.join(engine.root.to_s, "app", "models")
108
+ entry[:model_count] = Dir.glob(File.join(models_dir, "**/*.rb")).size if Dir.exist?(models_dir)
109
+ entry.compact
110
+ rescue => e
111
+ $stderr.puts "[rails-ai-context] discover_rails_engines failed: #{e.message}" if ENV["DEBUG"]
104
112
  nil
105
113
  end.sort_by { |e| e[:name] }
106
- rescue
114
+ rescue => e
115
+ $stderr.puts "[rails-ai-context] discover_rails_engines failed: #{e.message}" if ENV["DEBUG"]
107
116
  []
108
117
  end
109
118
  end
@@ -106,6 +106,8 @@ module RailsAiContext
106
106
  typescript: ts,
107
107
  monorepo: mono,
108
108
  build_tool: build,
109
+ api_clients: detect_api_clients(all_deps),
110
+ component_libraries: detect_component_libraries(all_deps),
109
111
  summary: build_summary(frameworks, mounting, build, ts, total_components)
110
112
  }
111
113
  rescue => e
@@ -413,12 +415,45 @@ module RailsAiContext
413
415
  false
414
416
  end
415
417
 
418
+ API_CLIENT_MARKERS = {
419
+ "axios" => "Axios", "ky" => "Ky", "got" => "Got",
420
+ "@tanstack/react-query" => "TanStack Query", "swr" => "SWR",
421
+ "apollo-client" => "Apollo Client", "@apollo/client" => "Apollo Client",
422
+ "urql" => "URQL", "graphql-request" => "graphql-request",
423
+ "relay-runtime" => "Relay"
424
+ }.freeze
425
+
426
+ COMPONENT_LIB_MARKERS = {
427
+ "@mui/material" => "MUI", "@chakra-ui/react" => "Chakra UI",
428
+ "@radix-ui/react-dialog" => "Radix UI", "@headlessui/react" => "Headless UI",
429
+ "antd" => "Ant Design", "@mantine/core" => "Mantine",
430
+ "shadcn-ui" => "shadcn/ui", "@shadcn/ui" => "shadcn/ui",
431
+ "daisyui" => "DaisyUI", "flowbite" => "Flowbite",
432
+ "primereact" => "PrimeReact", "vuetify" => "Vuetify",
433
+ "element-plus" => "Element Plus", "naive-ui" => "Naive UI"
434
+ }.freeze
435
+
436
+ def detect_api_clients(all_deps)
437
+ API_CLIENT_MARKERS.filter_map { |pkg, label| label if all_deps.key?(pkg) }.uniq
438
+ rescue => e
439
+ $stderr.puts "[rails-ai-context] detect_api_clients failed: #{e.message}" if ENV["DEBUG"]
440
+ []
441
+ end
442
+
443
+ def detect_component_libraries(all_deps)
444
+ COMPONENT_LIB_MARKERS.filter_map { |pkg, label| label if all_deps.key?(pkg) }.uniq
445
+ rescue => e
446
+ $stderr.puts "[rails-ai-context] detect_component_libraries failed: #{e.message}" if ENV["DEBUG"]
447
+ []
448
+ end
449
+
416
450
  def package_json_has_script?(name)
417
451
  path = File.join(root, "package.json")
418
452
  return false unless File.exist?(path)
419
453
  content = File.read(path, encoding: "bom|utf-8")
420
454
  content.include?("\"#{name}\"")
421
- rescue
455
+ rescue => e
456
+ $stderr.puts "[rails-ai-context] package_json_has_script? failed: #{e.message}" if ENV["DEBUG"]
422
457
  false
423
458
  end
424
459
  end
@@ -161,12 +161,58 @@ module RailsAiContext
161
161
  total_gems: specs.size,
162
162
  ruby_version: specs["ruby"]&.first,
163
163
  notable_gems: detect_notable_gems(specs),
164
- categories: categorize_gems(specs)
164
+ categories: categorize_gems(specs),
165
+ local_gems: detect_local_gems,
166
+ gem_groups: detect_gem_groups
165
167
  }
166
168
  end
167
169
 
168
170
  private
169
171
 
172
+ def detect_local_gems
173
+ gemfile = File.join(app.root, "Gemfile")
174
+ return [] unless File.exist?(gemfile)
175
+
176
+ content = File.read(gemfile)
177
+ local = []
178
+ content.each_line do |line|
179
+ next if line.strip.start_with?("#")
180
+ if (match = line.match(/gem\s+["'](\w[\w-]*)["'].*path:\s*["']([^"']+)["']/))
181
+ local << { name: match[1], source: "path", location: match[2] }
182
+ elsif (match = line.match(/gem\s+["'](\w[\w-]*)["'].*git:\s*["']([^"']+)["']/))
183
+ local << { name: match[1], source: "git", location: match[2] }
184
+ end
185
+ end
186
+ local
187
+ rescue => e
188
+ $stderr.puts "[rails-ai-context] detect_local_gems failed: #{e.message}" if ENV["DEBUG"]
189
+ []
190
+ end
191
+
192
+ def detect_gem_groups
193
+ gemfile = File.join(app.root, "Gemfile")
194
+ return {} unless File.exist?(gemfile)
195
+
196
+ content = File.read(gemfile)
197
+ groups = {}
198
+ current_group = nil
199
+ content.each_line do |line|
200
+ stripped = line.strip
201
+ next if stripped.start_with?("#")
202
+ if (match = stripped.match(/\Agroup\s+(.+?)\s+do\b/))
203
+ current_group = match[1].scan(/:(\w+)/).flatten
204
+ elsif stripped == "end" && current_group
205
+ current_group = nil
206
+ elsif current_group && (match = stripped.match(/\Agem\s+["'](\w[\w-]*)["']/))
207
+ current_group.each { |g| (groups[g] ||= []) << match[1] }
208
+ end
209
+ end
210
+ groups
211
+ rescue => e
212
+ $stderr.puts "[rails-ai-context] detect_gem_groups failed: #{e.message}" if ENV["DEBUG"]
213
+ {}
214
+ end
215
+
170
216
  def parse_lockfile(path)
171
217
  gems = {}
172
218
  in_gems = false
@@ -13,13 +13,16 @@ module RailsAiContext
13
13
  end
14
14
 
15
15
  def call
16
- {
16
+ result = {
17
17
  default_locale: I18n.default_locale.to_s,
18
18
  available_locales: I18n.available_locales.map(&:to_s).sort,
19
19
  backend: I18n.backend.class.name,
20
20
  locale_files: extract_locale_files,
21
- total_locale_files: count_locale_files
21
+ total_locale_files: count_locale_files,
22
+ locale_coverage: detect_locale_coverage
22
23
  }
24
+ result.merge!(detect_fallback_config)
25
+ result
23
26
  rescue => e
24
27
  { error: e.message }
25
28
  end
@@ -42,7 +45,8 @@ module RailsAiContext
42
45
  begin
43
46
  data = YAML.load_file(path, permitted_classes: [ Symbol ], aliases: true) || {}
44
47
  info[:key_count] = count_keys(data)
45
- rescue
48
+ rescue => e
49
+ $stderr.puts "[rails-ai-context] extract_locale_files failed: #{e.message}" if ENV["DEBUG"]
46
50
  info[:parse_error] = true
47
51
  end
48
52
  end
@@ -61,6 +65,48 @@ module RailsAiContext
61
65
  return 0 unless hash.is_a?(Hash)
62
66
  hash.sum { |_, v| v.is_a?(Hash) ? count_keys(v, depth: depth + 1) : 1 }
63
67
  end
68
+
69
+ def detect_fallback_config
70
+ config = {}
71
+ config[:fallbacks] = I18n.fallbacks.to_h.transform_values { |v| v.map(&:to_s) } if I18n.respond_to?(:fallbacks) && I18n.fallbacks
72
+ config
73
+ rescue => e
74
+ $stderr.puts "[rails-ai-context] detect_fallback_config failed: #{e.message}" if ENV["DEBUG"]
75
+ {}
76
+ end
77
+
78
+ def detect_locale_coverage
79
+ locales = I18n.available_locales
80
+ return {} if locales.size < 2
81
+
82
+ # Compare key counts between default and other locales
83
+ coverage = {}
84
+ default_count = count_keys_for_locale(I18n.default_locale)
85
+ locales.reject { |l| l == I18n.default_locale }.each do |locale|
86
+ locale_count = count_keys_for_locale(locale)
87
+ coverage[locale.to_s] = { keys: locale_count, coverage_pct: default_count > 0 ? ((locale_count.to_f / default_count) * 100).round(1) : 0 }
88
+ end
89
+ coverage
90
+ rescue => e
91
+ $stderr.puts "[rails-ai-context] detect_locale_coverage failed: #{e.message}" if ENV["DEBUG"]
92
+ {}
93
+ end
94
+
95
+ def count_keys_for_locale(locale)
96
+ path = Dir.glob(File.join(app.root, "config", "locales", "#{locale}.yml")).first
97
+ return 0 unless path && File.exist?(path)
98
+ data = YAML.safe_load(File.read(path), permitted_classes: [ Symbol ])
99
+ count_nested_keys(data)
100
+ rescue => e
101
+ $stderr.puts "[rails-ai-context] count_keys_for_locale failed: #{e.message}" if ENV["DEBUG"]
102
+ 0
103
+ end
104
+
105
+ def count_nested_keys(hash, count = 0)
106
+ return count unless hash.is_a?(Hash)
107
+ hash.each_value { |v| count = v.is_a?(Hash) ? count_nested_keys(v, count) : count + 1 }
108
+ count
109
+ end
64
110
  end
65
111
  end
66
112
  end
@@ -20,7 +20,9 @@ module RailsAiContext
20
20
  {
21
21
  jobs: jobs,
22
22
  mailers: extract_mailers,
23
- channels: extract_channels
23
+ channels: extract_channels,
24
+ recurring_jobs: extract_solid_queue_recurring,
25
+ sidekiq_config: extract_sidekiq_config
24
26
  }
25
27
  end
26
28
 
@@ -42,7 +44,8 @@ module RailsAiContext
42
44
  priority: job.priority
43
45
  }.compact
44
46
  end.sort_by { |j| j[:name] }
45
- rescue
47
+ rescue => e
48
+ $stderr.puts "[rails-ai-context] extract_jobs failed: #{e.message}" if ENV["DEBUG"]
46
49
  []
47
50
  end
48
51
 
@@ -73,17 +76,55 @@ module RailsAiContext
73
76
  perform_match = source.match(/def\s+perform\s*\(([^)]*)\)/)
74
77
  perform_signature = perform_match ? perform_match[1].strip : nil
75
78
 
79
+ callbacks = source.scan(/\b(before_enqueue|after_enqueue|before_perform|after_perform|around_perform|around_enqueue)\b/).flatten.uniq
80
+
76
81
  job = { name: name }
77
82
  job[:queue] = queue if queue
78
83
  job[:retry_on] = retry_on if retry_on.any?
79
84
  job[:discard_on] = discard_on if discard_on.any?
80
85
  job[:perform_signature] = perform_signature if perform_signature
86
+ job[:callbacks] = callbacks if callbacks.any?
81
87
  job
82
88
  end.sort_by { |j| j[:name] }
83
- rescue
89
+ rescue => e
90
+ $stderr.puts "[rails-ai-context] extract_jobs_from_source failed: #{e.message}" if ENV["DEBUG"]
91
+ []
92
+ end
93
+
94
+ def extract_solid_queue_recurring
95
+ paths = [
96
+ File.join(app.root, "config", "recurring.yml"),
97
+ File.join(app.root, "config", "solid_queue.yml")
98
+ ]
99
+ path = paths.find { |p| File.exist?(p) }
100
+ return [] unless path
101
+
102
+ content = File.read(path)
103
+ jobs = []
104
+ content.scan(/(\w+):\s*\n\s+class:\s*(\w+).*?(?:schedule:\s*["']?([^"'\n]+))?/m) do |name, klass, schedule|
105
+ jobs << { name: name, class: klass, schedule: schedule&.strip }.compact
106
+ end
107
+ jobs
108
+ rescue => e
109
+ $stderr.puts "[rails-ai-context] extract_solid_queue_recurring failed: #{e.message}" if ENV["DEBUG"]
84
110
  []
85
111
  end
86
112
 
113
+ def extract_sidekiq_config
114
+ path = File.join(app.root, "config", "sidekiq.yml")
115
+ return nil unless File.exist?(path)
116
+
117
+ content = File.read(path)
118
+ config = {}
119
+ config[:concurrency] = $1.to_i if content.match(/concurrency:\s*(\d+)/)
120
+ queues = content.scan(/-\s*(?:\[?\s*)?(\w+)/).flatten.uniq
121
+ config[:queues] = queues if queues.any?
122
+ config.empty? ? nil : config
123
+ rescue => e
124
+ $stderr.puts "[rails-ai-context] extract_sidekiq_config failed: #{e.message}" if ENV["DEBUG"]
125
+ nil
126
+ end
127
+
87
128
  def extract_mailers
88
129
  return [] unless defined?(ActionMailer::Base)
89
130
 
@@ -99,7 +140,8 @@ module RailsAiContext
99
140
  delivery_method: mailer.delivery_method.to_s
100
141
  }
101
142
  end.sort_by { |m| m[:name] }
102
- rescue
143
+ rescue => e
144
+ $stderr.puts "[rails-ai-context] extract_mailers failed: #{e.message}" if ENV["DEBUG"]
103
145
  []
104
146
  end
105
147
 
@@ -116,7 +158,8 @@ module RailsAiContext
116
158
  .map(&:to_s)
117
159
  }
118
160
  end.sort_by { |c| c[:name] }
119
- rescue
161
+ rescue => e
162
+ $stderr.puts "[rails-ai-context] extract_channels failed: #{e.message}" if ENV["DEBUG"]
120
163
  []
121
164
  end
122
165
  end
@@ -17,7 +17,8 @@ module RailsAiContext
17
17
  {
18
18
  custom_middleware: custom,
19
19
  middleware_stack: extract_middleware_stack,
20
- middleware_count: middleware_count(custom)
20
+ middleware_count: middleware_count(custom),
21
+ middleware_from_initializers: detect_middleware_from_initializers
21
22
  }
22
23
  rescue => e
23
24
  { error: e.message }
@@ -65,7 +66,8 @@ module RailsAiContext
65
66
  name = middleware.name || middleware.klass.to_s
66
67
  { name: name, category: categorize_middleware(name) }
67
68
  end
68
- rescue
69
+ rescue => e
70
+ $stderr.puts "[rails-ai-context] extract_middleware_stack failed: #{e.message}" if ENV["DEBUG"]
69
71
  []
70
72
  end
71
73
 
@@ -74,10 +76,29 @@ module RailsAiContext
74
76
  total: app.middleware.size,
75
77
  custom: custom.size
76
78
  }
77
- rescue
79
+ rescue => e
80
+ $stderr.puts "[rails-ai-context] middleware_count failed: #{e.message}" if ENV["DEBUG"]
78
81
  {}
79
82
  end
80
83
 
84
+ def detect_middleware_from_initializers
85
+ init_dir = File.join(root, "config/initializers")
86
+ return [] unless Dir.exist?(init_dir)
87
+
88
+ additions = []
89
+ Dir.glob(File.join(init_dir, "*.rb")).each do |path|
90
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
91
+ content.scan(/config\.middleware\.(?:use|insert_before|insert_after|insert|unshift)\s+(\S+)/).each do |match|
92
+ name = match[0].gsub(/,.*/, "").strip
93
+ additions << { middleware: name, file: File.basename(path) } unless name.empty?
94
+ end
95
+ end
96
+ additions.uniq
97
+ rescue => e
98
+ $stderr.puts "[rails-ai-context] detect_middleware_from_initializers failed: #{e.message}" if ENV["DEBUG"]
99
+ []
100
+ end
101
+
81
102
  def categorize_middleware(name)
82
103
  case name
83
104
  when /ActionDispatch::SSL|ForceSSL/ then "security"
@@ -93,7 +93,8 @@ module RailsAiContext
93
93
  content = File.read(schema_path)
94
94
  match = content.match(/version:\s*([\d_]+)/)
95
95
  match ? match[1].delete("_") : nil
96
- rescue
96
+ rescue => e
97
+ $stderr.puts "[rails-ai-context] current_schema_version failed: #{e.message}" if ENV["DEBUG"]
97
98
  nil
98
99
  end
99
100
 
@@ -117,9 +118,11 @@ module RailsAiContext
117
118
  %w[
118
119
  create_table drop_table rename_table
119
120
  add_column remove_column rename_column change_column
121
+ change_column_default change_column_null
120
122
  add_index remove_index add_reference remove_reference
121
123
  add_foreign_key remove_foreign_key
122
124
  add_timestamps create_join_table enable_extension execute
125
+ add_check_constraint remove_check_constraint
123
126
  ].select { |action| content.include?(action) }
124
127
  end
125
128
  end
@@ -39,7 +39,8 @@ module RailsAiContext
39
39
  else
40
40
  Rails.application.eager_load!
41
41
  end
42
- rescue
42
+ rescue => e
43
+ $stderr.puts "[rails-ai-context] eager_load_models! failed: #{e.message}" if ENV["DEBUG"]
43
44
  # In some environments (CI, Claude Code) eager_load may partially fail
44
45
  nil
45
46
  end
@@ -90,6 +91,14 @@ module RailsAiContext
90
91
  instance_methods: extract_public_instance_methods(model)
91
92
  }
92
93
 
94
+ # STI hierarchy detection
95
+ sti_info = extract_sti_info(model)
96
+ details[:sti] = sti_info if sti_info
97
+
98
+ # Enum prefix/suffix options
99
+ enum_options = extract_enum_options(model)
100
+ details[:enum_options] = enum_options if enum_options.any?
101
+
93
102
  # Source-based macro extractions
94
103
  macros = extract_source_macros(model)
95
104
  details.merge!(macros)
@@ -101,6 +110,45 @@ module RailsAiContext
101
110
  details.compact
102
111
  end
103
112
 
113
+ def extract_sti_info(model)
114
+ has_type_column = if model.connected? && model.table_exists?
115
+ model.columns_hash.key?("type")
116
+ else
117
+ # Fallback: check schema.rb for type column
118
+ schema_path = File.join(app.root.to_s, "db", "schema.rb")
119
+ if File.exist?(schema_path)
120
+ schema = File.read(schema_path)
121
+ table_section = schema[/create_table\s+"#{Regexp.escape(model.table_name)}".*?end/m]
122
+ table_section&.match?(/t\.\w+\s+"type"/)
123
+ end
124
+ end
125
+
126
+ return nil unless has_type_column
127
+
128
+ children = if model.respond_to?(:descendants)
129
+ model.descendants.map(&:name).compact.sort
130
+ elsif model.respond_to?(:subclasses)
131
+ model.subclasses.map(&:name).compact.sort
132
+ else
133
+ []
134
+ end
135
+
136
+ parent = model.superclass
137
+ sti_parent = if parent && parent != ActiveRecord::Base &&
138
+ (!defined?(ApplicationRecord) || parent != ApplicationRecord)
139
+ parent.name
140
+ end
141
+
142
+ {
143
+ sti_base: sti_parent.nil? && children.any?,
144
+ sti_parent: sti_parent,
145
+ sti_children: children.empty? ? nil : children
146
+ }.compact
147
+ rescue => e
148
+ $stderr.puts "[rails-ai-context] extract_sti_info failed: #{e.message}" if ENV["DEBUG"]
149
+ nil
150
+ end
151
+
104
152
  def extract_associations(model)
105
153
  model.reflect_on_all_associations.map do |assoc|
106
154
  detail = {
@@ -135,7 +183,8 @@ module RailsAiContext
135
183
  source.scan(/^\s*scope\s+:(\w+)\s*,\s*->\s*(?:\([^)]*\)\s*)?\{([^}]*)\}/m).map do |name, body|
136
184
  { name: name, body: body.strip }
137
185
  end
138
- rescue
186
+ rescue => e
187
+ $stderr.puts "[rails-ai-context] extract_scopes failed: #{e.message}" if ENV["DEBUG"]
139
188
  []
140
189
  end
141
190
 
@@ -146,7 +195,8 @@ module RailsAiContext
146
195
  return [] unless source_path && File.exist?(source_path)
147
196
 
148
197
  File.read(source_path).scan(/^\s*validate\s+:(\w+)/).flatten
149
- rescue
198
+ rescue => e
199
+ $stderr.puts "[rails-ai-context] extract_custom_validates failed: #{e.message}" if ENV["DEBUG"]
150
200
  []
151
201
  end
152
202
 
@@ -162,6 +212,35 @@ module RailsAiContext
162
212
  model.defined_enums.transform_values { |mapping| mapping.dup }
163
213
  end
164
214
 
215
+ def extract_enum_options(model)
216
+ source_path = model_source_path(model)
217
+ return {} unless source_path && File.exist?(source_path)
218
+
219
+ source = File.read(source_path)
220
+ options = {}
221
+ source.each_line do |line|
222
+ next unless line.match?(/\A\s*enum\s+/)
223
+ # Match both `enum :name, { ... }` and `enum name: { ... }` forms
224
+ name_match = line.match(/enum\s+:(\w+)/) || line.match(/enum\s+(\w+):/)
225
+ next unless name_match
226
+ enum_name = name_match[1]
227
+ entry = {}
228
+ entry[:prefix] = true if line.match?(/_?prefix:\s*true/)
229
+ entry[:suffix] = true if line.match?(/_?suffix:\s*true/)
230
+ if (prefix_val = line.match(/_?prefix:\s*:(\w+)/))
231
+ entry[:prefix] = prefix_val[1]
232
+ end
233
+ if (suffix_val = line.match(/_?suffix:\s*:(\w+)/))
234
+ entry[:suffix] = suffix_val[1]
235
+ end
236
+ options[enum_name] = entry if entry.any?
237
+ end
238
+ options
239
+ rescue => e
240
+ $stderr.puts "[rails-ai-context] extract_enum_options failed: #{e.message}" if ENV["DEBUG"]
241
+ {}
242
+ end
243
+
165
244
  def extract_callbacks(model)
166
245
  callback_types = %i[
167
246
  before_validation after_validation
@@ -174,7 +253,7 @@ module RailsAiContext
174
253
 
175
254
  result = callback_types.each_with_object({}) do |type, hash|
176
255
  callbacks = model.send(:"_#{type}_callbacks").reject do |cb|
177
- cb.filter.to_s.start_with?(*EXCLUDED_CALLBACKS) || cb.filter.is_a?(Proc)
256
+ cb.filter.nil? || cb.filter.to_s.start_with?(*EXCLUDED_CALLBACKS) || cb.filter.is_a?(Proc)
178
257
  end
179
258
 
180
259
  next if callbacks.empty?
@@ -185,7 +264,8 @@ module RailsAiContext
185
264
  # If reflection returned nothing, fall back to source parsing
186
265
  return result if result.any?
187
266
  extract_callbacks_from_source(model)
188
- rescue
267
+ rescue => e
268
+ $stderr.puts "[rails-ai-context] extract_callbacks failed: #{e.message}" if ENV["DEBUG"]
189
269
  extract_callbacks_from_source(model)
190
270
  end
191
271
 
@@ -200,11 +280,17 @@ module RailsAiContext
200
280
  if (match = line.match(/\A\s*(before_validation|after_validation|before_save|after_save|before_create|after_create|before_update|after_update|before_destroy|after_destroy|after_commit|after_rollback)\s+:(\w+)/))
201
281
  type = match[1]
202
282
  method_name = match[2]
283
+ on_match = line.match(/on:\s*(?::(\w+)|\[([^\]]+)\])/)
284
+ if on_match && type.start_with?("after_commit")
285
+ events = on_match[1] ? [ on_match[1] ] : on_match[2].scan(/:(\w+)/).flatten
286
+ type = events.map { |e| "after_commit_on_#{e}" }.join(", ")
287
+ end
203
288
  (callbacks[type] ||= []) << method_name
204
289
  end
205
290
  end
206
291
  callbacks
207
- rescue
292
+ rescue => e
293
+ $stderr.puts "[rails-ai-context] extract_callbacks_from_source failed: #{e.message}" if ENV["DEBUG"]
208
294
  {}
209
295
  end
210
296
 
@@ -276,7 +362,8 @@ module RailsAiContext
276
362
  in_class_methods = false if in_class_methods && line.match?(/\A\s*end\s*$/) && !line.match?(/def/)
277
363
  end
278
364
  methods.uniq
279
- rescue
365
+ rescue => e
366
+ $stderr.puts "[rails-ai-context] extract_source_class_methods failed: #{e.message}" if ENV["DEBUG"]
280
367
  []
281
368
  end
282
369
 
@@ -326,13 +413,16 @@ module RailsAiContext
326
413
  source.each_line do |line|
327
414
  in_private = true if line.match?(/\A\s*private\s*$/)
328
415
  next if in_private
416
+ # Also detect inline private/protected modifiers
417
+ next if line.match?(/\A\s*(?:private|protected)\s+def\s/)
329
418
  next if line.match?(/\A\s*def self\./)
330
419
  if (match = line.match(/\A\s*def (\w+[?!]?)/))
331
420
  methods << match[1] unless match[1] == "initialize"
332
421
  end
333
422
  end
334
423
  methods.uniq
335
- rescue
424
+ rescue => e
425
+ $stderr.puts "[rails-ai-context] extract_source_instance_methods failed: #{e.message}" if ENV["DEBUG"]
336
426
  []
337
427
  end
338
428
 
@@ -350,7 +440,8 @@ module RailsAiContext
350
440
  ])
351
441
  end
352
442
  methods
353
- rescue
443
+ rescue => e
444
+ $stderr.puts "[rails-ai-context] generated_association_methods failed: #{e.message}" if ENV["DEBUG"]
354
445
  []
355
446
  end
356
447
 
@@ -372,6 +463,10 @@ module RailsAiContext
372
463
  macros[:serialize] = source.scan(/\bserialize\s+:(\w+)/).flatten if source.match?(/\bserialize\s+:/)
373
464
  macros[:store] = source.scan(/\bstore(?:_accessor)?\s+:(\w+)/).flatten if source.match?(/\bstore(?:_accessor)?\s+:/)
374
465
 
466
+ # attribute API declarations (e.g. attribute :field, :type)
467
+ attributes = source.scan(/\battribute\s+:(\w+),\s*:(\w+)/).map { |name, type| { name: name, type: type } }
468
+ macros[:attributes] = attributes if attributes.any?
469
+
375
470
  # Constants with value lists (e.g. STATUSES = %w[pending completed])
376
471
  constants = source.scan(/\b([A-Z][A-Z_]+)\s*=\s*%[wi]\[([^\]]+)\]/).map do |name, values|
377
472
  { name: name, values: values.split }
@@ -390,7 +485,8 @@ module RailsAiContext
390
485
 
391
486
  # Remove empty arrays
392
487
  macros.reject { |_, v| v.is_a?(Array) && v.empty? }
393
- rescue
488
+ rescue => e
489
+ $stderr.puts "[rails-ai-context] extract_source_macros failed: #{e.message}" if ENV["DEBUG"]
394
490
  {}
395
491
  end
396
492
 
@@ -443,7 +539,8 @@ module RailsAiContext
443
539
  macros[:token_generation] = tokens if tokens.any?
444
540
 
445
541
  macros
446
- rescue
542
+ rescue => e
543
+ $stderr.puts "[rails-ai-context] extract_detailed_macros failed: #{e.message}" if ENV["DEBUG"]
447
544
  {}
448
545
  end
449
546