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
@@ -79,11 +79,13 @@ module RailsAiContext
79
79
  end
80
80
 
81
81
  text_response(output)
82
- rescue ActiveRecord::ConnectionNotEstablished
83
- text_response("Database connection unavailable. Ensure database is running and config/database.yml is correct.")
82
+ rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError => e
83
+ text_response("Database unavailable: #{clean_error_message(e.message)}\n\n**Troubleshooting:**\n- Check `config/database.yml` for correct host/port/credentials\n- Try `RAILS_ENV=test` if the development DB is remote\n- Run `bin/rails db:create` if the database doesn't exist yet")
84
84
  rescue ActiveRecord::StatementInvalid => e
85
85
  if e.message.match?(/timeout|statement_timeout|MAX_EXECUTION_TIME/i)
86
86
  text_response("Query exceeded #{config.query_timeout} second timeout. Simplify the query or add indexes.")
87
+ elsif e.message.match?(/could not find|does not exist|Unknown database/i)
88
+ text_response("Database not found: #{clean_error_message(e.message)}\n\n**Troubleshooting:**\n- Run `bin/rails db:create` to create the database\n- Check `config/database.yml` for the correct database name\n- Try `RAILS_ENV=test` if the development DB is remote")
87
89
  else
88
90
  text_response("SQL error: #{clean_error_message(e.message)}")
89
91
  end
@@ -269,7 +269,10 @@ module RailsAiContext
269
269
  private_class_method def self.available_log_files
270
270
  log_dir = File.join(Rails.root.to_s, "log")
271
271
  return [] unless Dir.exist?(log_dir)
272
- Dir.glob(File.join(log_dir, "*.log")).map { |f| File.basename(f) }.sort
272
+ Dir.glob(File.join(log_dir, "*.log"))
273
+ .map { |f| File.basename(f) }
274
+ .select { |f| f.match?(/\A[\w.\-]+\.log\z/) } # Only clean filenames (alphanumeric, dots, hyphens, underscores)
275
+ .sort
273
276
  end
274
277
  end
275
278
  end
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RailsAiContext
6
+ module Tools
7
+ class ReviewChanges < BaseTool
8
+ tool_name "rails_review_changes"
9
+ description "PR/commit review context: shows changed files with relevant schema/model/route context per file, " \
10
+ "detects warnings (missing indexes, removed validations, changed associations, new routes without tests). " \
11
+ "Use when: reviewing changes before merging, understanding what a commit changed and its impact. " \
12
+ "Key params: ref (default 'HEAD' for uncommitted, or 'main', 'HEAD~3', commit SHA)."
13
+
14
+ input_schema(
15
+ properties: {
16
+ ref: {
17
+ type: "string",
18
+ description: "Git ref to diff against. 'HEAD' = uncommitted changes (default). 'main' = diff from main. 'HEAD~3' = last 3 commits. 'abc123' = specific commit."
19
+ },
20
+ files: {
21
+ type: "array",
22
+ items: { type: "string" },
23
+ description: "Filter to specific files (relative to Rails root). Omit to review all changed files."
24
+ }
25
+ }
26
+ )
27
+
28
+ annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: false, open_world_hint: true)
29
+
30
+ MAX_DIFF_LINES_PER_FILE = 30
31
+
32
+ def self.call(ref: "HEAD", files: nil, server_context: nil)
33
+ root = Rails.root.to_s
34
+
35
+ # Verify git is available
36
+ _, status = Open3.capture2("git", "rev-parse", "--git-dir", chdir: root)
37
+ unless status.success?
38
+ return text_response("Not a git repository. `rails_review_changes` requires a git repository.\n\n**To initialize:** `git init && git add -A && git commit -m 'Initial commit'`")
39
+ end
40
+
41
+ changed = get_changed_files(ref, root)
42
+ changed = changed.select { |f| files.any? { |filter| f.include?(filter) } } if files&.any?
43
+
44
+ if changed.empty?
45
+ return text_response("No changes found for ref '#{ref}'.#{files ? " Filter: #{files.join(', ')}" : ""}")
46
+ end
47
+
48
+ # Classify files
49
+ classified = changed.map { |f| { file: f, type: classify_file(f) } }
50
+
51
+ # Get commit log
52
+ commits = get_commit_log(ref, root)
53
+
54
+ # Build output
55
+ lines = [ "# Review: #{ref}", "" ]
56
+
57
+ # Summary
58
+ type_counts = classified.group_by { |c| c[:type] }.transform_values(&:size)
59
+ summary_parts = type_counts.map { |type, count| "#{count} #{type}" }
60
+ lines << "**#{changed.size} files changed** (#{summary_parts.join(', ')})"
61
+ lines << ""
62
+
63
+ if commits
64
+ lines << "## Commits"
65
+ lines << "```"
66
+ lines << commits
67
+ lines << "```"
68
+ lines << ""
69
+ end
70
+
71
+ # Detect warnings
72
+ warnings = detect_warnings(classified, root, ref)
73
+ if warnings.any?
74
+ lines << "## Warnings"
75
+ warnings.each { |w| lines << "- #{w}" }
76
+ lines << ""
77
+ end
78
+
79
+ # File-by-file context — cap at 20 files to prevent overflow
80
+ max_files = 20
81
+ show_files = classified.first(max_files)
82
+ lines << "## File-by-File Context (#{show_files.size} of #{classified.size})"
83
+ lines << ""
84
+
85
+ show_files.each do |entry|
86
+ file_lines = gather_file_context(entry[:file], entry[:type], root, ref)
87
+ lines.concat(file_lines)
88
+ end
89
+
90
+ if classified.size > max_files
91
+ remaining = classified[max_files..].map { |e| e[:file] }
92
+ lines << "## Remaining #{remaining.size} files (not shown)"
93
+ remaining.each { |f| lines << "- #{f}" }
94
+ lines << ""
95
+ end
96
+
97
+ # Next steps
98
+ rb_files = classified.select { |c| c[:file].end_with?(".rb") }.map { |c| c[:file] }
99
+ if rb_files.any?
100
+ file_list = rb_files.first(10).map { |f| "\"#{f}\"" }.join(", ")
101
+ lines << "_Next: `rails_validate(files:[#{file_list}], level:\"rails\")` to validate all changes._"
102
+ end
103
+
104
+ text_response(lines.join("\n"))
105
+ rescue => e
106
+ text_response("Review error: #{e.message}")
107
+ end
108
+
109
+ class << self
110
+ private
111
+
112
+ def get_changed_files(ref, root)
113
+ if ref == "HEAD"
114
+ staged, _ = Open3.capture2("git", "diff", "--cached", "--name-only", chdir: root)
115
+ unstaged, _ = Open3.capture2("git", "diff", "--name-only", chdir: root)
116
+ untracked, _ = Open3.capture2("git", "ls-files", "--others", "--exclude-standard", chdir: root)
117
+ (staged.lines + unstaged.lines + untracked.lines).map(&:strip).reject(&:empty?).uniq
118
+ else
119
+ # Try three-dot (since divergence from ref)
120
+ output, status = Open3.capture2("git", "diff", "--name-only", "#{ref}...HEAD", chdir: root)
121
+ unless status.success?
122
+ # Fall back to two-dot
123
+ output, status = Open3.capture2("git", "diff", "--name-only", "#{ref}..HEAD", chdir: root)
124
+ unless status.success?
125
+ # Fall back to single ref diff
126
+ output, _ = Open3.capture2("git", "diff", "--name-only", ref, chdir: root)
127
+ end
128
+ end
129
+ output.lines.map(&:strip).reject(&:empty?).uniq
130
+ end
131
+ end
132
+
133
+ def get_commit_log(ref, root)
134
+ return nil if ref == "HEAD"
135
+ output, status = Open3.capture2("git", "log", "--oneline", "-10", "#{ref}..HEAD", chdir: root)
136
+ return nil unless status.success? && !output.strip.empty?
137
+ output.strip
138
+ end
139
+
140
+ def classify_file(path)
141
+ case path
142
+ when %r{\Aapp/models/} then :model
143
+ when %r{\Aapp/controllers/} then :controller
144
+ when %r{\Adb/migrate/} then :migration
145
+ when %r{\Aapp/views/} then :view
146
+ when %r{\Aconfig/routes} then :routes
147
+ when %r{\A(spec|test)/} then :test
148
+ when %r{\Aapp/services/} then :service
149
+ when %r{\Aapp/jobs/} then :job
150
+ when %r{\Aapp/javascript/} then :javascript
151
+ when %r{\Aconfig/} then :config
152
+ else :other
153
+ end
154
+ end
155
+
156
+ def gather_file_context(file, type, root, ref) # rubocop:disable Metrics
157
+ lines = [ "### #{file} (#{type})", "" ]
158
+
159
+ # Show diff summary
160
+ diff = get_file_diff(file, root, ref)
161
+ if diff
162
+ diff_lines = diff.lines
163
+ added = diff_lines.count { |l| l.start_with?("+") && !l.start_with?("+++") }
164
+ removed = diff_lines.count { |l| l.start_with?("-") && !l.start_with?("---") }
165
+ lines << "+#{added} / -#{removed} lines"
166
+
167
+ # Show truncated diff
168
+ content_lines = diff_lines.reject { |l| l.start_with?("diff ", "index ", "--- ", "+++ ") }
169
+ if content_lines.size > MAX_DIFF_LINES_PER_FILE
170
+ lines << "```diff"
171
+ lines.concat(content_lines.first(MAX_DIFF_LINES_PER_FILE).map(&:rstrip))
172
+ lines << "# ... #{content_lines.size - MAX_DIFF_LINES_PER_FILE} more lines"
173
+ lines << "```"
174
+ elsif content_lines.any?
175
+ lines << "```diff"
176
+ lines.concat(content_lines.map(&:rstrip))
177
+ lines << "```"
178
+ end
179
+ end
180
+
181
+ # Pull relevant context per file type
182
+ case type
183
+ when :model
184
+ model_name = File.basename(file, ".rb").camelize
185
+ begin
186
+ result = GetModelDetails.call(model: model_name, detail: "standard")
187
+ text = result.content.first[:text]
188
+ lines << "" << "**Model context:** #{model_name}" unless text.include?("not found")
189
+ rescue; end
190
+
191
+ when :controller
192
+ ctrl_name = File.basename(file, ".rb").camelize
193
+ snake = ctrl_name.underscore.delete_suffix("_controller")
194
+ begin
195
+ result = GetRoutes.call(controller: snake, detail: "summary")
196
+ text = result.content.first[:text]
197
+ lines << "" << "**Routes:**" << text unless text.include?("not found") || text.include?("No routes")
198
+ rescue; end
199
+
200
+ when :migration
201
+ # Parse migration for table/column info
202
+ full_path = File.join(root, file)
203
+ if File.exist?(full_path)
204
+ source = File.read(full_path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue nil
205
+ if source
206
+ tables = source.scan(/(?:create_table|add_column|remove_column|rename_column|add_index|add_reference)\s+:(\w+)/).flatten.uniq
207
+ if tables.any?
208
+ lines << "" << "**Affects tables:** #{tables.join(', ')}"
209
+ tables.first(2).each do |t|
210
+ begin
211
+ result = GetSchema.call(table: t, detail: "summary")
212
+ text = result.content.first[:text]
213
+ lines << " #{t}: #{text.lines.first&.strip}" unless text.include?("not found")
214
+ rescue; end
215
+ end
216
+ end
217
+ end
218
+ end
219
+
220
+ when :routes
221
+ begin
222
+ result = GetRoutes.call(detail: "summary")
223
+ lines << "" << "**Current routes:** #{result.content.first[:text].lines.first&.strip}"
224
+ rescue; end
225
+ end
226
+
227
+ lines << ""
228
+ lines
229
+ end
230
+
231
+ def get_file_diff(file, root, ref)
232
+ if ref == "HEAD"
233
+ output, status = Open3.capture2("git", "diff", "--", file, chdir: root)
234
+ if !status.success? || output.strip.empty?
235
+ output, status = Open3.capture2("git", "diff", "--cached", "--", file, chdir: root)
236
+ end
237
+ else
238
+ output, status = Open3.capture2("git", "diff", ref, "--", file, chdir: root)
239
+ end
240
+ status.success? && !output.strip.empty? ? output : nil
241
+ end
242
+
243
+ def detect_warnings(classified, root, ref) # rubocop:disable Metrics
244
+ warnings = []
245
+
246
+ migration_files = classified.select { |c| c[:type] == :migration }
247
+ model_files = classified.select { |c| c[:type] == :model }
248
+ test_files = classified.select { |c| c[:type] == :test }
249
+ controller_files = classified.select { |c| c[:type] == :controller }
250
+
251
+ # Check migrations for missing indexes on foreign key columns
252
+ migration_files.each do |entry|
253
+ full_path = File.join(root, entry[:file])
254
+ next unless File.exist?(full_path)
255
+ source = File.read(full_path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
256
+
257
+ # New columns ending in _id without add_index
258
+ source.scan(/add_column\s+:\w+,\s+:(\w+_id)/).flatten.each do |col|
259
+ unless source.include?("add_index") && source.include?(col)
260
+ warnings << "**Missing index**: `#{entry[:file]}` adds `#{col}` without an index"
261
+ end
262
+ end
263
+
264
+ # add_reference without index: false check
265
+ source.scan(/add_reference\s+:(\w+),\s+:(\w+)/).each do |_table, ref_name|
266
+ if source.include?("index: false")
267
+ warnings << "**Disabled index**: `#{entry[:file]}` adds reference `#{ref_name}` with `index: false`"
268
+ end
269
+ end
270
+ end
271
+
272
+ # Check model diffs for removed validations
273
+ model_files.each do |entry|
274
+ diff = get_file_diff(entry[:file], root, ref)
275
+ next unless diff
276
+ removed_validations = diff.lines.select { |l| l.start_with?("-") && l.match?(/validates?\s/) }
277
+ removed_validations.each do |line|
278
+ warnings << "**Removed validation**: `#{entry[:file]}` — `#{line.strip[1..].strip}`"
279
+ end
280
+ end
281
+
282
+ # Check for controller changes without test changes
283
+ changed_ctrls = controller_files.map { |c| File.basename(c[:file], ".rb") }
284
+ changed_tests = test_files.map { |t| File.basename(t[:file], ".rb") }
285
+ changed_ctrls.each do |ctrl|
286
+ test_name = ctrl.sub("_controller", "_controller_test")
287
+ spec_name = ctrl.sub("_controller", "_controller_spec")
288
+ request_name = ctrl.sub("_controller", "_spec")
289
+ unless changed_tests.any? { |t| t == test_name || t == spec_name || t == request_name || t.include?(ctrl.delete_suffix("_controller")) }
290
+ warnings << "**No test changes**: `#{ctrl}.rb` was modified but no corresponding test file was changed"
291
+ end
292
+ end
293
+
294
+ warnings
295
+ end
296
+ end
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Tools
5
+ class RuntimeInfo < BaseTool
6
+ tool_name "rails_runtime_info"
7
+ description "Live runtime state: database connection pool, table sizes, pending migrations, cache stats, job queues. " \
8
+ "Use when: debugging performance, checking database health, verifying deployment state, or understanding infrastructure. " \
9
+ "Key params: detail (summary/standard/full), section (database/cache/jobs/connections)."
10
+
11
+ input_schema(
12
+ properties: {
13
+ detail: {
14
+ type: "string",
15
+ enum: %w[summary standard full],
16
+ description: "Detail level. summary: one-line per section. standard: tables with sizes (default). full: index usage + cache details."
17
+ },
18
+ section: {
19
+ type: "string",
20
+ enum: %w[database cache jobs connections],
21
+ description: "Filter to one section. Omit for all sections."
22
+ }
23
+ }
24
+ )
25
+
26
+ annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: false, open_world_hint: false)
27
+
28
+ def self.call(detail: "standard", section: nil, server_context: nil)
29
+ lines = [ "# Runtime Info", "" ]
30
+
31
+ sections = section ? [ section ] : %w[connections database cache jobs]
32
+
33
+ sections.each do |s|
34
+ result = case s
35
+ when "connections" then gather_connection_pool
36
+ when "database" then gather_database(detail)
37
+ when "cache" then gather_cache
38
+ when "jobs" then gather_jobs(detail)
39
+ end
40
+ lines.concat(result) if result&.any?
41
+ end
42
+
43
+ if lines.size <= 2
44
+ lines << "_No runtime data available. Ensure the app has a database connection._"
45
+ end
46
+
47
+ text_response(lines.join("\n"))
48
+ rescue => e
49
+ text_response("Runtime info error: #{e.message}")
50
+ end
51
+
52
+ class << self
53
+ private
54
+
55
+ # ── Connection pool ──────────────────────────────────────────────
56
+
57
+ def gather_connection_pool
58
+ stat = ActiveRecord::Base.connection_pool.stat
59
+ lines = [ "## Connection Pool", "" ]
60
+ lines << "| Metric | Value |"
61
+ lines << "|--------|-------|"
62
+ lines << "| Pool size | #{stat[:size]} |"
63
+ lines << "| Connections | #{stat[:connections]} |"
64
+ lines << "| Busy | #{stat[:busy]} |"
65
+ lines << "| Idle | #{stat[:idle]} |"
66
+ lines << "| Dead | #{stat[:dead]} |"
67
+ lines << "| Waiting | #{stat[:waiting]} |"
68
+ lines << "| Checkout timeout | #{stat[:checkout_timeout]}s |"
69
+
70
+ utilization = stat[:size] > 0 ? ((stat[:busy].to_f / stat[:size]) * 100).round : 0
71
+ lines << "" << "Pool utilization: #{utilization}%"
72
+ lines << "**Warning:** Pool is full (#{stat[:busy]}/#{stat[:size]} busy, #{stat[:waiting]} waiting)" if stat[:waiting] > 0
73
+ lines << ""
74
+ lines
75
+ rescue => e
76
+ [ "## Connection Pool", "", "_Not available: #{e.message}_", "" ]
77
+ end
78
+
79
+ # ── Database section ─────────────────────────────────────────────
80
+
81
+ def gather_database(detail) # rubocop:disable Metrics
82
+ conn = ActiveRecord::Base.connection
83
+ adapter = conn.adapter_name.downcase
84
+ lines = [ "## Database", "" ]
85
+ lines << "**Adapter:** #{conn.adapter_name}"
86
+
87
+ # Table sizes
88
+ sizes = gather_table_sizes(conn, adapter)
89
+ if sizes&.any?
90
+ lines << "" << "### Table Sizes"
91
+ lines << "| Table | Size |"
92
+ lines << "|-------|------|"
93
+ sizes.first(detail == "summary" ? 5 : 30).each do |row|
94
+ lines << "| #{row[:name]} | #{format_bytes(row[:bytes])} |"
95
+ end
96
+ total = sizes.sum { |r| r[:bytes] }
97
+ lines << "| **Total** | **#{format_bytes(total)}** |"
98
+ lines << "_#{sizes.size - 30} more tables..._" if detail != "summary" && sizes.size > 30
99
+ end
100
+
101
+ # Pending migrations
102
+ pending = gather_pending_migrations
103
+ if pending
104
+ if pending.empty?
105
+ lines << "" << "**Migrations:** all up to date"
106
+ else
107
+ lines << "" << "**Pending migrations:** #{pending.size}"
108
+ pending.each { |m| lines << "- #{m}" }
109
+ end
110
+ end
111
+
112
+ # Index usage (full detail only)
113
+ if detail == "full"
114
+ index_usage = gather_index_usage(conn, adapter)
115
+ if index_usage&.any?
116
+ lines << "" << "### Index Usage"
117
+ unused = index_usage.select { |i| i[:scans] == 0 }
118
+ if unused.any?
119
+ lines << "**Unused indexes (0 scans):**"
120
+ unused.first(10).each { |i| lines << "- `#{i[:index]}` on `#{i[:table]}`" }
121
+ end
122
+ hot = index_usage.sort_by { |i| -(i[:scans] || 0) }.first(5)
123
+ lines << "" << "**Most used indexes:**"
124
+ hot.each { |i| lines << "- `#{i[:index]}` on `#{i[:table]}` — #{i[:scans]} scans" }
125
+ end
126
+ end
127
+
128
+ lines << ""
129
+ lines
130
+ rescue => e
131
+ [ "## Database", "", "_Not available: #{e.message}_", "" ]
132
+ end
133
+
134
+ def gather_table_sizes(conn, adapter)
135
+ case adapter
136
+ when /postgresql/
137
+ sql = "SELECT relname AS name, pg_total_relation_size(relid) AS bytes FROM pg_stat_user_tables ORDER BY bytes DESC"
138
+ conn.select_all(sql).map { |r| { name: r["name"], bytes: r["bytes"].to_i } }
139
+ when /mysql/
140
+ sql = "SELECT table_name AS name, (data_length + index_length) AS bytes FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = DATABASE() ORDER BY bytes DESC"
141
+ conn.select_all(sql).map { |r| { name: r["name"], bytes: r["bytes"].to_i } }
142
+ when /sqlite/
143
+ # Try dbstat virtual table first, fall back to whole-DB size
144
+ begin
145
+ result = conn.select_all("SELECT name, SUM(pgsize) AS bytes FROM dbstat GROUP BY name ORDER BY bytes DESC")
146
+ return result.map { |r| { name: r["name"], bytes: r["bytes"].to_i } } if result.any?
147
+ rescue
148
+ nil
149
+ end
150
+ # Fallback: whole database size
151
+ page_count = conn.select_value("PRAGMA page_count").to_i
152
+ page_size = conn.select_value("PRAGMA page_size").to_i
153
+ total = page_count * page_size
154
+ tables = conn.select_values("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
155
+ tables.map { |t| { name: t, bytes: total / [ tables.size, 1 ].max } }
156
+ else
157
+ nil
158
+ end
159
+ rescue
160
+ nil
161
+ end
162
+
163
+ def gather_pending_migrations
164
+ migrate_dir = File.join(Rails.root, "db/migrate")
165
+ return nil unless Dir.exist?(migrate_dir)
166
+
167
+ context = ActiveRecord::MigrationContext.new(migrate_dir)
168
+ pending = if context.respond_to?(:pending_migrations)
169
+ context.pending_migrations
170
+ else
171
+ ActiveRecord::Migrator.new(:up, context.migrations).pending_migrations
172
+ end
173
+ pending.map { |m| "#{m.version} — #{m.name}" }
174
+ rescue
175
+ nil
176
+ end
177
+
178
+ def gather_index_usage(conn, adapter)
179
+ case adapter
180
+ when /postgresql/
181
+ sql = "SELECT relname AS table, indexrelname AS index, idx_scan AS scans FROM pg_stat_user_indexes ORDER BY idx_scan ASC"
182
+ conn.select_all(sql).map { |r| { table: r["table"], index: r["index"], scans: r["scans"].to_i } }
183
+ else
184
+ nil
185
+ end
186
+ rescue
187
+ nil
188
+ end
189
+
190
+ # ── Cache section ────────────────────────────────────────────────
191
+
192
+ def gather_cache
193
+ cache = Rails.cache
194
+ lines = [ "## Cache", "" ]
195
+ lines << "**Store:** #{cache.class.name.demodulize}"
196
+
197
+ if cache.respond_to?(:stats)
198
+ stats = cache.stats
199
+ lines << "**Stats:** #{stats.inspect}"
200
+ elsif cache.respond_to?(:redis)
201
+ begin
202
+ info = cache.redis.info("stats")
203
+ hits = info["keyspace_hits"].to_i
204
+ misses = info["keyspace_misses"].to_i
205
+ total = hits + misses
206
+ hit_rate = total > 0 ? ((hits.to_f / total) * 100).round(1) : 0
207
+ lines << "**Hit rate:** #{hit_rate}% (#{hits} hits, #{misses} misses)"
208
+ lines << "**Memory:** #{cache.redis.info("memory")["used_memory_human"]}"
209
+ rescue => e
210
+ lines << "_Redis stats not available: #{e.message}_"
211
+ end
212
+ else
213
+ lines << "_Stats not available for #{cache.class.name}. Supported: Redis, MemoryStore._"
214
+ end
215
+
216
+ lines << ""
217
+ lines
218
+ rescue => e
219
+ [ "## Cache", "", "_Not available: #{e.message}_", "" ]
220
+ end
221
+
222
+ # ── Jobs section ─────────────────────────────────────────────────
223
+
224
+ def gather_jobs(detail)
225
+ lines = [ "## Background Jobs", "" ]
226
+
227
+ # Active Job adapter
228
+ adapter_name = begin
229
+ name = ActiveJob::Base.queue_adapter_name
230
+ name.empty? ? "not configured" : name
231
+ rescue
232
+ "not available"
233
+ end
234
+ lines << "**Adapter:** #{adapter_name}"
235
+
236
+ # Sidekiq stats (if available)
237
+ if defined?(Sidekiq)
238
+ begin
239
+ require "sidekiq/api"
240
+ stats = Sidekiq::Stats.new
241
+ lines << "**Enqueued:** #{stats.enqueued}"
242
+ lines << "**Processed:** #{stats.processed}"
243
+ lines << "**Failed:** #{stats.failed}"
244
+ lines << "**Scheduled:** #{stats.scheduled_size}"
245
+ lines << "**Retries:** #{stats.retry_size}"
246
+ lines << "**Dead:** #{stats.dead_size}"
247
+
248
+ if detail == "full"
249
+ queues = Sidekiq::Queue.all
250
+ if queues.any?
251
+ lines << "" << "### Queues"
252
+ lines << "| Queue | Size | Latency |"
253
+ lines << "|-------|------|---------|"
254
+ queues.each do |q|
255
+ lines << "| #{q.name} | #{q.size} | #{q.latency.round(1)}s |"
256
+ end
257
+ end
258
+ end
259
+ rescue => e
260
+ lines << "_Sidekiq stats error: #{e.message}_"
261
+ end
262
+ else
263
+ lines << "_Sidekiq not loaded. Queue stats unavailable._"
264
+ end
265
+
266
+ lines << ""
267
+ lines
268
+ rescue => e
269
+ [ "## Background Jobs", "", "_Not available: #{e.message}_", "" ]
270
+ end
271
+
272
+ # ── Helpers ──────────────────────────────────────────────────────
273
+
274
+ def format_bytes(bytes)
275
+ return "0 B" if bytes.nil? || bytes == 0
276
+ if bytes >= 1_073_741_824
277
+ "#{(bytes / 1_073_741_824.0).round(1)} GB"
278
+ elsif bytes >= 1_048_576
279
+ "#{(bytes / 1_048_576.0).round(1)} MB"
280
+ elsif bytes >= 1024
281
+ "#{(bytes / 1024.0).round(1)} KB"
282
+ else
283
+ "#{bytes} B"
284
+ end
285
+ end
286
+ end
287
+ end
288
+ end
289
+ end
@@ -87,10 +87,10 @@ module RailsAiContext
87
87
  search_pattern = case match_type
88
88
  when "definition"
89
89
  cleaned = pattern.sub(/\A\s*def\s+/, "")
90
- "^\\s*def\\s+(self\\.)?#{cleaned}"
90
+ "^\\s*def\\s+(self\\.)?#{Regexp.escape(cleaned)}"
91
91
  when "class"
92
92
  cleaned = pattern.sub(/\A\s*(class|module)\s+/, "")
93
- "^\\s*(class|module)\\s+\\w*#{cleaned}"
93
+ "^\\s*(class|module)\\s+\\w*#{Regexp.escape(cleaned)}"
94
94
  when "call"
95
95
  pattern
96
96
  else
@@ -202,6 +202,22 @@ module RailsAiContext
202
202
  cmd << "--glob=!#{p}"
203
203
  end
204
204
 
205
+ # Exclude generated AI context files (not source code)
206
+ # Claude
207
+ cmd << "--glob=!CLAUDE.md"
208
+ cmd << "--glob=!.claude/"
209
+ # Cursor
210
+ cmd << "--glob=!.cursor/"
211
+ cmd << "--glob=!.cursorrules"
212
+ # GitHub Copilot
213
+ cmd << "--glob=!.github/copilot-instructions.md"
214
+ cmd << "--glob=!.github/instructions/"
215
+ # OpenCode
216
+ cmd << "--glob=!AGENTS.md"
217
+ cmd << "--glob=!**/AGENTS.md"
218
+ # JSON export
219
+ cmd << "--glob=!.ai-context.json"
220
+
205
221
  # Exclude test/spec directories if requested
206
222
  if exclude_tests
207
223
  cmd << "--glob=!test/"
@@ -238,11 +254,13 @@ module RailsAiContext
238
254
  excluded = RailsAiContext.configuration.excluded_paths
239
255
  sensitive = RailsAiContext.configuration.sensitive_patterns
240
256
  test_dirs = %w[test/ spec/ features/]
257
+ ai_context_files = %w[CLAUDE.md AGENTS.md .claude/ .cursor/ .cursorrules .github/copilot-instructions.md .github/instructions/ .ai-context.json]
241
258
 
242
259
  Dir.glob(File.join(search_path, glob)).each do |file|
243
260
  relative = file.sub("#{root}/", "")
244
261
  next if excluded.any? { |ex| relative.start_with?(ex) }
245
262
  next if sensitive_file?(relative, sensitive)
263
+ next if ai_context_files.any? { |p| relative.start_with?(p) || relative == p }
246
264
  next if exclude_tests && test_dirs.any? { |td| relative.start_with?(td) }
247
265
 
248
266
  File.readlines(file).each_with_index do |line, idx|
@@ -260,9 +278,10 @@ module RailsAiContext
260
278
 
261
279
  private_class_method def self.sensitive_file?(relative_path, patterns)
262
280
  basename = File.basename(relative_path)
281
+ flags = File::FNM_DOTMATCH | File::FNM_CASEFOLD
263
282
  patterns.any? do |pattern|
264
- File.fnmatch(pattern, relative_path, File::FNM_DOTMATCH) ||
265
- File.fnmatch(pattern, basename, File::FNM_DOTMATCH)
283
+ File.fnmatch(pattern, relative_path, flags) ||
284
+ File.fnmatch(pattern, basename, flags)
266
285
  end
267
286
  end
268
287