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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -8
- data/CLAUDE.md +4 -4
- data/CONTRIBUTING.md +1 -1
- data/README.md +7 -7
- data/SECURITY.md +2 -1
- data/docs/GUIDE.md +3 -3
- data/lib/generators/rails_ai_context/install/install_generator.rb +2 -2
- data/lib/rails_ai_context/configuration.rb +4 -2
- data/lib/rails_ai_context/doctor.rb +6 -1
- data/lib/rails_ai_context/fingerprinter.rb +24 -0
- data/lib/rails_ai_context/introspectors/component_introspector.rb +122 -7
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +31 -26
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +183 -6
- data/lib/rails_ai_context/introspectors/view_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +61 -8
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +13 -22
- data/lib/rails_ai_context/serializers/claude_serializer.rb +15 -3
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +15 -3
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +3 -3
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +5 -5
- data/lib/rails_ai_context/serializers/design_system_helper.rb +8 -1
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +3 -3
- data/lib/rails_ai_context/serializers/opencode_serializer.rb +0 -1
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +15 -9
- data/lib/rails_ai_context/server.rb +8 -1
- data/lib/rails_ai_context/tools/analyze_feature.rb +24 -1
- data/lib/rails_ai_context/tools/base_tool.rb +78 -1
- data/lib/rails_ai_context/tools/dependency_graph.rb +4 -1
- data/lib/rails_ai_context/tools/diagnose.rb +135 -8
- data/lib/rails_ai_context/tools/generate_test.rb +87 -7
- data/lib/rails_ai_context/tools/get_callbacks.rb +27 -4
- data/lib/rails_ai_context/tools/get_component_catalog.rb +11 -2
- data/lib/rails_ai_context/tools/get_context.rb +71 -8
- data/lib/rails_ai_context/tools/get_conventions.rb +59 -0
- data/lib/rails_ai_context/tools/get_design_system.rb +45 -7
- data/lib/rails_ai_context/tools/get_edit_context.rb +3 -2
- data/lib/rails_ai_context/tools/get_env.rb +51 -24
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +100 -9
- data/lib/rails_ai_context/tools/get_model_details.rb +20 -0
- data/lib/rails_ai_context/tools/get_partial_interface.rb +12 -5
- data/lib/rails_ai_context/tools/get_schema.rb +1 -0
- data/lib/rails_ai_context/tools/get_stimulus.rb +13 -7
- data/lib/rails_ai_context/tools/get_turbo_map.rb +35 -2
- data/lib/rails_ai_context/tools/get_view.rb +65 -9
- data/lib/rails_ai_context/tools/migration_advisor.rb +10 -3
- data/lib/rails_ai_context/tools/onboard.rb +413 -27
- data/lib/rails_ai_context/tools/performance_check.rb +45 -28
- data/lib/rails_ai_context/tools/query.rb +28 -2
- data/lib/rails_ai_context/tools/read_logs.rb +4 -1
- data/lib/rails_ai_context/tools/review_changes.rb +27 -17
- data/lib/rails_ai_context/tools/runtime_info.rb +289 -0
- data/lib/rails_ai_context/tools/search_code.rb +23 -4
- data/lib/rails_ai_context/tools/security_scan.rb +7 -1
- data/lib/rails_ai_context/tools/session_context.rb +137 -0
- data/lib/rails_ai_context/tools/validate.rb +5 -0
- data/lib/rails_ai_context/version.rb +1 -1
- 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
|
|
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"))
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
test_name =
|
|
278
|
-
spec_name =
|
|
279
|
-
request_name =
|
|
280
|
-
|
|
281
|
-
|
|
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,
|
|
265
|
-
File.fnmatch(pattern, basename,
|
|
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
|
-
|
|
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
|