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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- 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 +18 -10
- 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 +10 -19
- data/lib/rails_ai_context/serializers/claude_serializer.rb +42 -11
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +14 -3
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/design_system_helper.rb +8 -1
- data/lib/rails_ai_context/serializers/opencode_serializer.rb +5 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +165 -64
- data/lib/rails_ai_context/server.rb +12 -1
- data/lib/rails_ai_context/tools/base_tool.rb +63 -1
- data/lib/rails_ai_context/tools/diagnose.rb +436 -0
- data/lib/rails_ai_context/tools/generate_test.rb +571 -0
- 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 +70 -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 +19 -0
- data/lib/rails_ai_context/tools/get_partial_interface.rb +1 -1
- 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 +4 -0
- data/lib/rails_ai_context/tools/onboard.rb +755 -0
- data/lib/rails_ai_context/tools/query.rb +4 -2
- data/lib/rails_ai_context/tools/read_logs.rb +4 -1
- data/lib/rails_ai_context/tools/review_changes.rb +299 -0
- 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 +132 -0
- data/lib/rails_ai_context/version.rb +1 -1
- 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
|
|
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"))
|
|
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,
|
|
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
|
|