rails-ai-context 4.2.2 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/CLAUDE.md +4 -4
- data/CONTRIBUTING.md +1 -1
- data/README.md +7 -7
- data/SECURITY.md +1 -1
- data/docs/GUIDE.md +3 -3
- data/lib/generators/rails_ai_context/install/install_generator.rb +2 -2
- data/lib/rails_ai_context/doctor.rb +8 -1
- data/lib/rails_ai_context/introspectors/model_introspector.rb +1 -1
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -1
- data/lib/rails_ai_context/serializers/claude_serializer.rb +32 -12
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +2 -1
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +3 -2
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +2 -1
- data/lib/rails_ai_context/serializers/opencode_serializer.rb +6 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +159 -64
- data/lib/rails_ai_context/server.rb +5 -1
- data/lib/rails_ai_context/tools/diagnose.rb +309 -0
- data/lib/rails_ai_context/tools/generate_test.rb +519 -0
- data/lib/rails_ai_context/tools/get_context.rb +3 -3
- data/lib/rails_ai_context/tools/onboard.rb +453 -0
- data/lib/rails_ai_context/tools/query.rb +13 -1
- data/lib/rails_ai_context/tools/review_changes.rb +290 -0
- data/lib/rails_ai_context/tools/search_code.rb +6 -3
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +8 -4
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module RailsAiContext
|
|
6
|
+
module Tools
|
|
7
|
+
class Diagnose < BaseTool
|
|
8
|
+
tool_name "rails_diagnose"
|
|
9
|
+
description "One-call error diagnosis: parses the error, classifies it, gathers controller/model/schema context, " \
|
|
10
|
+
"shows recent git changes, pulls relevant logs, and suggests a fix. " \
|
|
11
|
+
"Use when: you hit an error and need to understand why. " \
|
|
12
|
+
"Key params: error (required — paste the error message), file, line, action (controller#action)."
|
|
13
|
+
|
|
14
|
+
input_schema(
|
|
15
|
+
properties: {
|
|
16
|
+
error: {
|
|
17
|
+
type: "string",
|
|
18
|
+
description: "Error message or exception (e.g. 'NoMethodError: undefined method `foo` for nil:NilClass')."
|
|
19
|
+
},
|
|
20
|
+
file: {
|
|
21
|
+
type: "string",
|
|
22
|
+
description: "File where error occurs, relative to Rails root (e.g. 'app/controllers/cooks_controller.rb')."
|
|
23
|
+
},
|
|
24
|
+
line: {
|
|
25
|
+
type: "integer",
|
|
26
|
+
description: "Line number where the error occurs."
|
|
27
|
+
},
|
|
28
|
+
action: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "Controller#action format (e.g. 'cooks#create'). Pulls full action context."
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
required: %w[error]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: false, open_world_hint: true)
|
|
37
|
+
|
|
38
|
+
# ── Error classification ──────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
ERROR_CLASSIFICATIONS = {
|
|
41
|
+
/NoMethodError|NameError/ => {
|
|
42
|
+
type: :nil_reference,
|
|
43
|
+
likely: "A method was called on nil or an undefined name was referenced. Check for: missing association, unfetched record, typo in method name.",
|
|
44
|
+
fix: "1. Check the variable/object is not nil before calling the method\n2. Verify the association or attribute exists on the model\n3. Use `&.` safe navigation if the value can legitimately be nil"
|
|
45
|
+
},
|
|
46
|
+
/RecordNotFound/ => {
|
|
47
|
+
type: :record_not_found,
|
|
48
|
+
likely: "A `.find()` or `.find_by!()` call failed because the record doesn't exist. Common causes: stale URL, deleted record, wrong ID parameter.",
|
|
49
|
+
fix: "1. Use `.find_by` (returns nil) instead of `.find` (raises)\n2. Add a rescue handler or `before_action :set_record` with proper error handling\n3. Check the route parameter name matches what the controller expects"
|
|
50
|
+
},
|
|
51
|
+
/RecordInvalid|RecordNotSaved/ => {
|
|
52
|
+
type: :validation_failure,
|
|
53
|
+
likely: "A `.save!`, `.create!`, or `.update!` call failed validation. The model has validations that the submitted data doesn't satisfy.",
|
|
54
|
+
fix: "1. Use `.save` (returns false) instead of `.save!` (raises) for user-facing flows\n2. Check model validations against the params being submitted\n3. Verify required associations exist before saving"
|
|
55
|
+
},
|
|
56
|
+
/RoutingError/ => {
|
|
57
|
+
type: :routing,
|
|
58
|
+
likely: "No route matches the request. The URL or HTTP verb doesn't match any defined route.",
|
|
59
|
+
fix: "1. Run `rails_get_routes` to see all defined routes\n2. Check the HTTP verb (GET vs POST vs PATCH) matches\n3. Verify the route is not restricted by constraints"
|
|
60
|
+
},
|
|
61
|
+
/ParameterMissing/ => {
|
|
62
|
+
type: :strong_params,
|
|
63
|
+
likely: "A required parameter is missing from the request. The `params.require(:key)` call didn't find the expected key.",
|
|
64
|
+
fix: "1. Check the form field names match what strong params expects\n2. Verify the parameter nesting (e.g., `params[:user][:name]` vs `params[:name]`)\n3. Check the controller's `*_params` method"
|
|
65
|
+
},
|
|
66
|
+
/StatementInvalid|UndefinedColumn|UndefinedTable/ => {
|
|
67
|
+
type: :schema_mismatch,
|
|
68
|
+
likely: "A database query references a column or table that doesn't exist. Common after migrations or in stale schema.",
|
|
69
|
+
fix: "1. Run `rails db:migrate` to apply pending migrations\n2. Check `rails_get_schema(table:\"...\")` for actual column names\n3. Verify the migration was generated correctly"
|
|
70
|
+
},
|
|
71
|
+
/Template::Error|ActionView/ => {
|
|
72
|
+
type: :view_error,
|
|
73
|
+
likely: "An error occurred while rendering a view template. The underlying error is usually a NoMethodError or missing local variable.",
|
|
74
|
+
fix: "1. Check the instance variables set in the controller action\n2. Verify partial locals are passed correctly\n3. Use `rails_get_view(controller:\"...\")` to see template structure"
|
|
75
|
+
},
|
|
76
|
+
/ArgumentError/ => {
|
|
77
|
+
type: :argument_error,
|
|
78
|
+
likely: "A method received wrong number or type of arguments.",
|
|
79
|
+
fix: "1. Check the method signature matches the call site\n2. Use `rails_search_code(pattern:\"method_name\", match_type:\"trace\")` to see definition and callers"
|
|
80
|
+
}
|
|
81
|
+
}.freeze
|
|
82
|
+
|
|
83
|
+
def self.call(error:, file: nil, line: nil, action: nil, server_context: nil)
|
|
84
|
+
return text_response("The `error` parameter is required.") if error.nil? || error.strip.empty?
|
|
85
|
+
|
|
86
|
+
parsed = parse_error(error)
|
|
87
|
+
classification = classify_error(parsed)
|
|
88
|
+
|
|
89
|
+
lines = [ "# Error Diagnosis", "" ]
|
|
90
|
+
lines << "**Error:** `#{parsed[:exception_class] || 'Unknown'}`"
|
|
91
|
+
lines << "**Message:** #{parsed[:message]}"
|
|
92
|
+
lines << "**Classification:** #{classification[:type]}"
|
|
93
|
+
lines << ""
|
|
94
|
+
|
|
95
|
+
# Likely cause
|
|
96
|
+
lines << "## Likely Cause"
|
|
97
|
+
lines << classification[:likely]
|
|
98
|
+
lines << ""
|
|
99
|
+
|
|
100
|
+
# Suggested fix
|
|
101
|
+
lines << "## Suggested Fix"
|
|
102
|
+
lines << classification[:fix]
|
|
103
|
+
lines << ""
|
|
104
|
+
|
|
105
|
+
# Gather context based on parameters and error type
|
|
106
|
+
lines.concat(gather_context(parsed, classification, file, line, action))
|
|
107
|
+
|
|
108
|
+
# Recent git changes
|
|
109
|
+
git_section = gather_git_context(file, parsed[:file_refs])
|
|
110
|
+
lines.concat(git_section) if git_section.any?
|
|
111
|
+
|
|
112
|
+
# Recent error logs
|
|
113
|
+
log_section = gather_log_context(parsed[:exception_class])
|
|
114
|
+
lines.concat(log_section) if log_section.any?
|
|
115
|
+
|
|
116
|
+
# Next steps
|
|
117
|
+
lines << "## Next Steps"
|
|
118
|
+
if file
|
|
119
|
+
lines << "_Use `rails_get_edit_context(file:\"#{file}\", near:\"#{parsed[:method_name] || line || parsed[:exception_class]}\")` to see the code._"
|
|
120
|
+
end
|
|
121
|
+
if parsed[:method_name]
|
|
122
|
+
lines << "_Use `rails_search_code(pattern:\"#{parsed[:method_name]}\", match_type:\"trace\")` to trace the method._"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
text_response(lines.join("\n"))
|
|
126
|
+
rescue => e
|
|
127
|
+
text_response("Diagnosis error: #{e.message}")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
class << self
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def parse_error(error_string)
|
|
134
|
+
result = { exception_class: nil, message: error_string.strip, file_refs: [], method_name: nil }
|
|
135
|
+
|
|
136
|
+
# Extract exception class: "NoMethodError: ..." or "ActiveRecord::RecordNotFound ..."
|
|
137
|
+
if (m = error_string.match(/\A([\w:]+(?:Error|Exception|Invalid|NotFound|NotSaved|Missing))\s*[:—]\s*(.*)/m))
|
|
138
|
+
result[:exception_class] = m[1]
|
|
139
|
+
result[:message] = m[2].strip
|
|
140
|
+
elsif (m = error_string.match(/\A([\w:]+::\w+)\s*[:—]\s*(.*)/m))
|
|
141
|
+
result[:exception_class] = m[1]
|
|
142
|
+
result[:message] = m[2].strip
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Extract file:line references
|
|
146
|
+
error_string.scan(%r{(app/\S+\.rb):(\d+)}).each do |file, line|
|
|
147
|
+
result[:file_refs] << { file: file, line: line.to_i }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Extract method name from "undefined method `foo`" or "undefined method 'foo'"
|
|
151
|
+
if (m = result[:message].match(/undefined method [`'](\w+[?!]?)[`']/))
|
|
152
|
+
result[:method_name] = m[1]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
result
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def classify_error(parsed)
|
|
159
|
+
error_str = "#{parsed[:exception_class]} #{parsed[:message]}"
|
|
160
|
+
|
|
161
|
+
ERROR_CLASSIFICATIONS.each do |pattern, info|
|
|
162
|
+
return info if error_str.match?(pattern)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
{
|
|
166
|
+
type: :unknown,
|
|
167
|
+
likely: "Unable to automatically classify this error. Review the full error message and stack trace.",
|
|
168
|
+
fix: "1. Check the error message for clues about what went wrong\n2. Use `rails_search_code` to find the failing code\n3. Use `rails_read_logs(level:\"ERROR\")` for more context"
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def gather_context(parsed, classification, file, line, action) # rubocop:disable Metrics
|
|
173
|
+
lines = []
|
|
174
|
+
|
|
175
|
+
# Controller context from action: parameter
|
|
176
|
+
if action
|
|
177
|
+
ctrl, act = action.split("#", 2)
|
|
178
|
+
if ctrl && act
|
|
179
|
+
begin
|
|
180
|
+
ctrl_class = ctrl.end_with?("Controller") ? ctrl : "#{ctrl.camelize}Controller"
|
|
181
|
+
result = GetControllers.call(controller: ctrl_class, action: act)
|
|
182
|
+
text = result.content.first[:text]
|
|
183
|
+
unless text.include?("not found")
|
|
184
|
+
lines << "## Controller Context"
|
|
185
|
+
lines << text
|
|
186
|
+
lines << ""
|
|
187
|
+
end
|
|
188
|
+
rescue => e
|
|
189
|
+
lines << "## Controller Context"
|
|
190
|
+
lines << "_Could not load: #{e.message}_"
|
|
191
|
+
lines << ""
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# File context
|
|
197
|
+
if file && line
|
|
198
|
+
begin
|
|
199
|
+
result = GetEditContext.call(file: file, near: parsed[:method_name] || line.to_s)
|
|
200
|
+
text = result.content.first[:text]
|
|
201
|
+
unless text.include?("not found") || text.include?("not allowed")
|
|
202
|
+
lines << "## Code Context"
|
|
203
|
+
lines << text
|
|
204
|
+
lines << ""
|
|
205
|
+
end
|
|
206
|
+
rescue => e
|
|
207
|
+
lines << "## Code Context"
|
|
208
|
+
lines << "_Could not load: #{e.message}_"
|
|
209
|
+
lines << ""
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Schema context for schema_mismatch errors
|
|
214
|
+
if classification[:type] == :schema_mismatch
|
|
215
|
+
# Try to extract table name from error
|
|
216
|
+
table = parsed[:message].match(/(?:table|relation)\s+["']?(\w+)["']?/i)&.[](1)
|
|
217
|
+
if table
|
|
218
|
+
begin
|
|
219
|
+
result = GetSchema.call(table: table)
|
|
220
|
+
text = result.content.first[:text]
|
|
221
|
+
unless text.include?("not found")
|
|
222
|
+
lines << "## Schema Context"
|
|
223
|
+
lines << text
|
|
224
|
+
lines << ""
|
|
225
|
+
end
|
|
226
|
+
rescue; end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Model context for validation errors
|
|
231
|
+
if classification[:type] == :validation_failure
|
|
232
|
+
# Try to extract model from file path or error
|
|
233
|
+
model_name = if file&.match?(%r{app/models/(.+)\.rb})
|
|
234
|
+
file.match(%r{app/models/(.+)\.rb})[1].camelize
|
|
235
|
+
end
|
|
236
|
+
if model_name
|
|
237
|
+
begin
|
|
238
|
+
result = GetModelDetails.call(model: model_name)
|
|
239
|
+
text = result.content.first[:text]
|
|
240
|
+
unless text.include?("not found")
|
|
241
|
+
lines << "## Model Context"
|
|
242
|
+
lines << text
|
|
243
|
+
lines << ""
|
|
244
|
+
end
|
|
245
|
+
rescue; end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Trace method if we know the method name
|
|
250
|
+
if parsed[:method_name] && lines.none? { |l| l.include?("Code Context") }
|
|
251
|
+
begin
|
|
252
|
+
result = SearchCode.call(pattern: parsed[:method_name], match_type: "trace")
|
|
253
|
+
text = result.content.first[:text]
|
|
254
|
+
unless text.include?("No results") || text.include?("No definition")
|
|
255
|
+
lines << "## Method Trace"
|
|
256
|
+
lines << text
|
|
257
|
+
lines << ""
|
|
258
|
+
end
|
|
259
|
+
rescue; end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
lines
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def gather_git_context(file, file_refs)
|
|
266
|
+
lines = []
|
|
267
|
+
root = Rails.root.to_s
|
|
268
|
+
|
|
269
|
+
files_to_check = [ file, *file_refs.map { |r| r[:file] } ].compact.uniq.first(3)
|
|
270
|
+
return lines if files_to_check.empty?
|
|
271
|
+
|
|
272
|
+
git_output = []
|
|
273
|
+
files_to_check.each do |f|
|
|
274
|
+
full = File.join(root, f)
|
|
275
|
+
next unless File.exist?(full)
|
|
276
|
+
output, status = Open3.capture2("git", "log", "--oneline", "-5", "--", f, chdir: root)
|
|
277
|
+
if status.success? && !output.strip.empty?
|
|
278
|
+
git_output << "**#{f}:**\n#{output.strip}"
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
if git_output.any?
|
|
283
|
+
lines << "## Recent Git Changes"
|
|
284
|
+
lines.concat(git_output)
|
|
285
|
+
lines << ""
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
lines
|
|
289
|
+
rescue
|
|
290
|
+
[]
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def gather_log_context(exception_class)
|
|
294
|
+
return [] unless exception_class
|
|
295
|
+
|
|
296
|
+
begin
|
|
297
|
+
result = ReadLogs.call(level: "ERROR", lines: 15, search: exception_class)
|
|
298
|
+
text = result.content.first[:text]
|
|
299
|
+
return [] if text.include?("Log file is empty") || text.include?("not found") || text.include?("No entries")
|
|
300
|
+
|
|
301
|
+
[ "## Recent Error Logs", text, "" ]
|
|
302
|
+
rescue
|
|
303
|
+
[]
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|