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.
@@ -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