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
@@ -44,9 +44,20 @@ module RailsAiContext
44
44
  MULTI_STATEMENT = /;\s*\S/
45
45
  ALLOWED_PREFIX = /\A\s*(SELECT|WITH|SHOW|EXPLAIN|DESCRIBE|DESC)\b/i
46
46
 
47
+ # SQL injection tautology patterns: OR 1=1, OR true, OR ''='', UNION SELECT, etc.
48
+ TAUTOLOGY_PATTERNS = [
49
+ /\bOR\s+1\s*=\s*1\b/i,
50
+ /\bOR\s+true\b/i,
51
+ /\bOR\s+'[^']*'\s*=\s*'[^']*'/i,
52
+ /\bOR\s+"[^"]*"\s*=\s*"[^"]*"/i,
53
+ /\bOR\s+\d+\s*=\s*\d+/i,
54
+ /\bUNION\s+(ALL\s+)?SELECT\b/i
55
+ ].freeze
56
+
47
57
  HARD_ROW_CAP = 1000
48
58
 
49
59
  def self.call(sql: nil, limit: nil, format: "table", server_context: nil, **_extra)
60
+ set_call_params(sql: sql&.truncate(60))
50
61
  # ── Environment guard ───────────────────────────────────────
51
62
  unless config.allow_query_in_production || !Rails.env.production?
52
63
  return text_response(
@@ -79,11 +90,13 @@ module RailsAiContext
79
90
  end
80
91
 
81
92
  text_response(output)
82
- rescue ActiveRecord::ConnectionNotEstablished
83
- text_response("Database connection unavailable. Ensure database is running and config/database.yml is correct.")
93
+ rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError => e
94
+ 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
95
  rescue ActiveRecord::StatementInvalid => e
85
96
  if e.message.match?(/timeout|statement_timeout|MAX_EXECUTION_TIME/i)
86
97
  text_response("Query exceeded #{config.query_timeout} second timeout. Simplify the query or add indexes.")
98
+ elsif e.message.match?(/could not find|does not exist|Unknown database/i)
99
+ 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
100
  else
88
101
  text_response("SQL error: #{clean_error_message(e.message)}")
89
102
  end
@@ -113,6 +126,10 @@ module RailsAiContext
113
126
  return [ false, "Blocked: sensitive SHOW command" ] if cleaned.match?(BLOCKED_SHOWS)
114
127
  return [ false, "Blocked: SELECT INTO creates a table" ] if cleaned.match?(SELECT_INTO)
115
128
 
129
+ # Check for SQL injection tautology patterns (OR 1=1, UNION SELECT, etc.)
130
+ tautology = TAUTOLOGY_PATTERNS.find { |p| cleaned.match?(p) }
131
+ return [ false, "Blocked: SQL injection pattern detected (#{cleaned[tautology]})" ] if tautology
132
+
116
133
  # Check blocked keywords before the allowed-prefix fallback so that
117
134
  # INSERT/UPDATE/DELETE/DROP etc. get a specific "Blocked" error
118
135
  # rather than the generic "Only SELECT... allowed" message.
@@ -220,6 +237,15 @@ module RailsAiContext
220
237
  # ── Column redaction (Layer 4) ──────────────────────────────────
221
238
  private_class_method def self.redact_results(result)
222
239
  redacted_cols = config.query_redacted_columns.map(&:downcase).to_set
240
+
241
+ # Auto-redact columns declared with `encrypts` in models
242
+ models_data = (SHARED_CACHE[:context] || cached_context)&.dig(:models)
243
+ if models_data.is_a?(Hash)
244
+ models_data.each_value do |data|
245
+ next unless data.is_a?(Hash)
246
+ (data[:encrypts] || []).each { |col| redacted_cols << col.to_s.downcase }
247
+ end
248
+ end
223
249
  columns = result.columns
224
250
  rows = result.rows
225
251
 
@@ -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
@@ -27,7 +27,7 @@ module RailsAiContext
27
27
 
28
28
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: false, open_world_hint: true)
29
29
 
30
- MAX_DIFF_LINES_PER_FILE = 60
30
+ MAX_DIFF_LINES_PER_FILE = 30
31
31
 
32
32
  def self.call(ref: "HEAD", files: nil, server_context: nil)
33
33
  root = Rails.root.to_s
@@ -35,7 +35,7 @@ module RailsAiContext
35
35
  # Verify git is available
36
36
  _, status = Open3.capture2("git", "rev-parse", "--git-dir", chdir: root)
37
37
  unless status.success?
38
- return text_response("Not a git repository. `rails_review_changes` requires git.")
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
39
  end
40
40
 
41
41
  changed = get_changed_files(ref, root)
@@ -76,15 +76,24 @@ module RailsAiContext
76
76
  lines << ""
77
77
  end
78
78
 
79
- # File-by-file context
80
- lines << "## File-by-File Context"
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})"
81
83
  lines << ""
82
84
 
83
- classified.each do |entry|
85
+ show_files.each do |entry|
84
86
  file_lines = gather_file_context(entry[:file], entry[:type], root, ref)
85
87
  lines.concat(file_lines)
86
88
  end
87
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
+
88
97
  # Next steps
89
98
  rb_files = classified.select { |c| c[:file].end_with?(".rb") }.map { |c| c[:file] }
90
99
  if rb_files.any?
@@ -177,7 +186,7 @@ module RailsAiContext
177
186
  result = GetModelDetails.call(model: model_name, detail: "standard")
178
187
  text = result.content.first[:text]
179
188
  lines << "" << "**Model context:** #{model_name}" unless text.include?("not found")
180
- rescue; end
189
+ rescue => e; $stderr.puts "[rails-ai-context] Context lookup skipped: #{e.message}"; end
181
190
 
182
191
  when :controller
183
192
  ctrl_name = File.basename(file, ".rb").camelize
@@ -186,7 +195,7 @@ module RailsAiContext
186
195
  result = GetRoutes.call(controller: snake, detail: "summary")
187
196
  text = result.content.first[:text]
188
197
  lines << "" << "**Routes:**" << text unless text.include?("not found") || text.include?("No routes")
189
- rescue; end
198
+ rescue => e; $stderr.puts "[rails-ai-context] Context lookup skipped: #{e.message}"; end
190
199
 
191
200
  when :migration
192
201
  # Parse migration for table/column info
@@ -202,7 +211,7 @@ module RailsAiContext
202
211
  result = GetSchema.call(table: t, detail: "summary")
203
212
  text = result.content.first[:text]
204
213
  lines << " #{t}: #{text.lines.first&.strip}" unless text.include?("not found")
205
- rescue; end
214
+ rescue => e; $stderr.puts "[rails-ai-context] Context lookup skipped: #{e.message}"; end
206
215
  end
207
216
  end
208
217
  end
@@ -212,7 +221,7 @@ module RailsAiContext
212
221
  begin
213
222
  result = GetRoutes.call(detail: "summary")
214
223
  lines << "" << "**Current routes:** #{result.content.first[:text].lines.first&.strip}"
215
- rescue; end
224
+ rescue => e; $stderr.puts "[rails-ai-context] Context lookup skipped: #{e.message}"; end
216
225
  end
217
226
 
218
227
  lines << ""
@@ -271,14 +280,15 @@ module RailsAiContext
271
280
  end
272
281
 
273
282
  # Check for controller changes without test changes
274
- changed_ctrls = controller_files.map { |c| File.basename(c[:file], ".rb") }
275
- changed_tests = test_files.map { |t| File.basename(t[:file], ".rb") }
276
- changed_ctrls.each do |ctrl|
277
- test_name = ctrl.sub("_controller", "_controller_test")
278
- spec_name = ctrl.sub("_controller", "_controller_spec")
279
- request_name = ctrl.sub("_controller", "_spec")
280
- unless changed_tests.any? { |t| t == test_name || t == spec_name || t == request_name || t.include?(ctrl.delete_suffix("_controller")) }
281
- warnings << "**No test changes**: `#{ctrl}.rb` was modified but no corresponding test file was changed"
283
+ controller_files.each do |entry|
284
+ basename = File.basename(entry[:file], ".rb")
285
+ next unless basename.end_with?("_controller")
286
+ test_name = basename.sub("_controller", "_controller_test")
287
+ spec_name = basename.sub("_controller", "_controller_spec")
288
+ request_name = basename.sub("_controller", "_spec")
289
+ ctrl_stem = basename.delete_suffix("_controller")
290
+ unless test_files.any? { |t| File.basename(t[:file], ".rb").then { |tb| tb == test_name || tb == spec_name || tb == request_name || tb.include?(ctrl_stem) } }
291
+ warnings << "**No test changes**: `#{entry[:file]}` was modified but no corresponding test file was changed"
282
292
  end
283
293
  end
284
294
 
@@ -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
 
@@ -123,7 +123,13 @@ module RailsAiContext
123
123
 
124
124
  if warnings.empty?
125
125
  scope = files&.any? ? " in #{files.join(', ')}" : ""
126
- return text_response("No security warnings found#{scope}. (#{checks_run} checks run)")
126
+ # Summarize what categories were checked for transparency
127
+ check_names = tracker.checks.checks_run.map do |c|
128
+ c.to_s.sub(/\ABrakeman::Checks::Check/, "").gsub(/([a-z])([A-Z])/, '\1 \2')
129
+ end
130
+ categories = check_names.first(6).join(", ")
131
+ categories += ", ..." if check_names.size > 6
132
+ return text_response("No security warnings found#{scope}. (#{checks_run} checks run: #{categories})")
127
133
  end
128
134
 
129
135
  case detail