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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  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 +18 -10
  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 +10 -19
  18. data/lib/rails_ai_context/serializers/claude_serializer.rb +42 -11
  19. data/lib/rails_ai_context/serializers/context_file_serializer.rb +14 -3
  20. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +1 -1
  21. data/lib/rails_ai_context/serializers/design_system_helper.rb +8 -1
  22. data/lib/rails_ai_context/serializers/opencode_serializer.rb +5 -2
  23. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +165 -64
  24. data/lib/rails_ai_context/server.rb +12 -1
  25. data/lib/rails_ai_context/tools/base_tool.rb +63 -1
  26. data/lib/rails_ai_context/tools/diagnose.rb +436 -0
  27. data/lib/rails_ai_context/tools/generate_test.rb +571 -0
  28. data/lib/rails_ai_context/tools/get_callbacks.rb +27 -4
  29. data/lib/rails_ai_context/tools/get_component_catalog.rb +11 -2
  30. data/lib/rails_ai_context/tools/get_context.rb +70 -8
  31. data/lib/rails_ai_context/tools/get_conventions.rb +59 -0
  32. data/lib/rails_ai_context/tools/get_design_system.rb +45 -7
  33. data/lib/rails_ai_context/tools/get_edit_context.rb +3 -2
  34. data/lib/rails_ai_context/tools/get_env.rb +51 -24
  35. data/lib/rails_ai_context/tools/get_frontend_stack.rb +100 -9
  36. data/lib/rails_ai_context/tools/get_model_details.rb +19 -0
  37. data/lib/rails_ai_context/tools/get_partial_interface.rb +1 -1
  38. data/lib/rails_ai_context/tools/get_stimulus.rb +13 -7
  39. data/lib/rails_ai_context/tools/get_turbo_map.rb +35 -2
  40. data/lib/rails_ai_context/tools/get_view.rb +65 -9
  41. data/lib/rails_ai_context/tools/migration_advisor.rb +4 -0
  42. data/lib/rails_ai_context/tools/onboard.rb +755 -0
  43. data/lib/rails_ai_context/tools/query.rb +4 -2
  44. data/lib/rails_ai_context/tools/read_logs.rb +4 -1
  45. data/lib/rails_ai_context/tools/review_changes.rb +299 -0
  46. data/lib/rails_ai_context/tools/runtime_info.rb +289 -0
  47. data/lib/rails_ai_context/tools/search_code.rb +23 -4
  48. data/lib/rails_ai_context/tools/security_scan.rb +7 -1
  49. data/lib/rails_ai_context/tools/session_context.rb +132 -0
  50. data/lib/rails_ai_context/version.rb +1 -1
  51. metadata +10 -4
@@ -0,0 +1,436 @@
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
+ # Section size limits for output truncation
41
+ MAX_TOTAL_OUTPUT = 20_000
42
+ MAX_SECTION_CHARS = {
43
+ controller_context: 3_000,
44
+ model_context: 3_000,
45
+ code_context: 3_000,
46
+ schema_context: 3_000,
47
+ method_trace: 3_000,
48
+ git_changes: 2_000,
49
+ logs: 2_000
50
+ }.freeze
51
+
52
+ ERROR_CLASSIFICATIONS = {
53
+ /NameError.*uninitialized constant/ => {
54
+ type: :name_error,
55
+ likely: "A class or module constant could not be found. This usually means a typo in the class/module name, a missing require, or an autoload issue.",
56
+ fix: "1. Check for typo in class/module name, missing require, or autoload issue\n2. Verify the file is in the correct autoload path (e.g. app/models, app/services)\n3. Run `rails_search_code(pattern:\"ClassName\", match_type:\"trace\")` to find where the constant is defined"
57
+ },
58
+ /NoMethodError/ => {
59
+ type: :nil_reference,
60
+ likely: "A method was called on nil or an undefined name was referenced. Check for: missing association, unfetched record, typo in method name.",
61
+ 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"
62
+ },
63
+ /NameError/ => {
64
+ type: :name_error,
65
+ likely: "An undefined name was referenced. This could be a typo in a variable, method, or constant name, or a missing require/autoload.",
66
+ fix: "1. Check for typo in class/module name, missing require, or autoload issue\n2. Verify the name is defined and accessible in the current scope\n3. Use `rails_search_code(pattern:\"name\", match_type:\"trace\")` to find where it is defined"
67
+ },
68
+ /RecordNotFound/ => {
69
+ type: :record_not_found,
70
+ likely: "A `.find()` or `.find_by!()` call failed because the record doesn't exist. Common causes: stale URL, deleted record, wrong ID parameter.",
71
+ 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"
72
+ },
73
+ /RecordInvalid|RecordNotSaved/ => {
74
+ type: :validation_failure,
75
+ likely: "A `.save!`, `.create!`, or `.update!` call failed validation. The model has validations that the submitted data doesn't satisfy.",
76
+ 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"
77
+ },
78
+ /RoutingError/ => {
79
+ type: :routing,
80
+ likely: "No route matches the request. The URL or HTTP verb doesn't match any defined route.",
81
+ 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"
82
+ },
83
+ /ParameterMissing/ => {
84
+ type: :strong_params,
85
+ likely: "A required parameter is missing from the request. The `params.require(:key)` call didn't find the expected key.",
86
+ 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"
87
+ },
88
+ /StatementInvalid|UndefinedColumn|UndefinedTable/ => {
89
+ type: :schema_mismatch,
90
+ likely: "A database query references a column or table that doesn't exist. Common after migrations or in stale schema.",
91
+ 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"
92
+ },
93
+ /Template::Error|ActionView/ => {
94
+ type: :view_error,
95
+ likely: "An error occurred while rendering a view template. The underlying error is usually a NoMethodError or missing local variable.",
96
+ 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"
97
+ },
98
+ /ArgumentError/ => {
99
+ type: :argument_error,
100
+ likely: "A method received wrong number or type of arguments.",
101
+ 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"
102
+ }
103
+ }.freeze
104
+
105
+ def self.call(error:, file: nil, line: nil, action: nil, server_context: nil)
106
+ return text_response("The `error` parameter is required.") if error.nil? || error.strip.empty?
107
+
108
+ parsed = parse_error(error)
109
+ classification = classify_error(parsed)
110
+
111
+ lines = [ "# Error Diagnosis", "" ]
112
+ lines << "**Error:** `#{parsed[:exception_class] || 'Unknown'}`"
113
+ lines << "**Message:** #{parsed[:message]}"
114
+ lines << "**Classification:** #{classification[:type]}"
115
+ lines << ""
116
+
117
+ # Likely cause — enriched with specific inference when possible
118
+ lines << "## Likely Cause"
119
+ lines << classification[:likely]
120
+ specific = infer_specific_cause(parsed, classification, file, action)
121
+ lines << "" << "**Specific:** #{specific}" if specific
122
+ lines << ""
123
+
124
+ # Suggested fix
125
+ lines << "## Suggested Fix"
126
+ lines << classification[:fix]
127
+ lines << ""
128
+
129
+ # Gather context based on parameters and error type
130
+ context_sections = gather_context(parsed, classification, file, line, action)
131
+
132
+ # Recent git changes
133
+ git_section = gather_git_context(file, parsed[:file_refs])
134
+
135
+ # Recent error logs
136
+ log_section = gather_log_context(parsed[:exception_class])
137
+
138
+ # Truncate large sections before assembling final output
139
+ context_sections = truncate_section(context_sections, "Controller Context", MAX_SECTION_CHARS[:controller_context])
140
+ context_sections = truncate_section(context_sections, "Code Context", MAX_SECTION_CHARS[:code_context])
141
+ context_sections = truncate_section(context_sections, "Schema Context", MAX_SECTION_CHARS[:schema_context])
142
+ context_sections = truncate_section(context_sections, "Model Context", MAX_SECTION_CHARS[:model_context])
143
+ context_sections = truncate_section(context_sections, "Method Trace", MAX_SECTION_CHARS[:method_trace])
144
+ git_section = truncate_section(git_section, "Recent Git Changes", MAX_SECTION_CHARS[:git_changes])
145
+ log_section = truncate_section(log_section, "Recent Error Logs", MAX_SECTION_CHARS[:logs])
146
+
147
+ lines.concat(context_sections)
148
+ lines.concat(git_section) if git_section.any?
149
+ lines.concat(log_section) if log_section.any?
150
+
151
+ # Next steps
152
+ lines << "## Next Steps"
153
+ if file
154
+ lines << "_Use `rails_get_edit_context(file:\"#{file}\", near:\"#{parsed[:method_name] || line || parsed[:exception_class]}\")` to see the code._"
155
+ end
156
+ if parsed[:method_name]
157
+ lines << "_Use `rails_search_code(pattern:\"#{parsed[:method_name]}\", match_type:\"trace\")` to trace the method._"
158
+ end
159
+
160
+ output = lines.join("\n")
161
+
162
+ # Final safety cap: if total output still exceeds limit, hard-truncate
163
+ if output.length > MAX_TOTAL_OUTPUT
164
+ output = output[0, MAX_TOTAL_OUTPUT] + "\n\n_... output truncated (#{output.length} chars exceeded #{MAX_TOTAL_OUTPUT} limit)._"
165
+ end
166
+
167
+ text_response(output)
168
+ rescue => e
169
+ text_response("Diagnosis error: #{e.message}")
170
+ end
171
+
172
+ class << self
173
+ private
174
+
175
+ def parse_error(error_string)
176
+ result = { exception_class: nil, message: error_string.strip, file_refs: [], method_name: nil }
177
+
178
+ # Extract exception class: "NoMethodError: ..." or "ActiveRecord::RecordNotFound ..."
179
+ if (m = error_string.match(/\A([\w:]+(?:Error|Exception|Invalid|NotFound|NotSaved|Missing))\s*[:—]\s*(.*)/m))
180
+ result[:exception_class] = m[1]
181
+ result[:message] = m[2].strip
182
+ elsif (m = error_string.match(/\A([\w:]+::\w+)\s*[:—]\s*(.*)/m))
183
+ result[:exception_class] = m[1]
184
+ result[:message] = m[2].strip
185
+ end
186
+
187
+ # Extract file:line references
188
+ error_string.scan(%r{(app/\S+\.rb):(\d+)}).each do |file, line|
189
+ result[:file_refs] << { file: file, line: line.to_i }
190
+ end
191
+
192
+ # Extract method name from "undefined method `foo`" or "undefined method 'foo'"
193
+ if (m = result[:message].match(/undefined method [`'](\w+[?!]?)[`']/))
194
+ result[:method_name] = m[1]
195
+ end
196
+
197
+ result
198
+ end
199
+
200
+ def classify_error(parsed)
201
+ error_str = "#{parsed[:exception_class]} #{parsed[:message]}"
202
+
203
+ ERROR_CLASSIFICATIONS.each do |pattern, info|
204
+ return info if error_str.match?(pattern)
205
+ end
206
+
207
+ {
208
+ type: :unknown,
209
+ likely: "Unable to automatically classify this error. Review the full error message and stack trace.",
210
+ 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"
211
+ }
212
+ end
213
+
214
+ def gather_context(parsed, classification, file, line, action) # rubocop:disable Metrics
215
+ lines = []
216
+
217
+ # Controller context from action: parameter
218
+ if action
219
+ ctrl, act = action.split("#", 2)
220
+ if ctrl && act
221
+ begin
222
+ ctrl_class = ctrl.end_with?("Controller") ? ctrl : "#{ctrl.camelize}Controller"
223
+ result = GetControllers.call(controller: ctrl_class, action: act)
224
+ text = result.content.first[:text]
225
+ unless text.include?("not found")
226
+ lines << "## Controller Context"
227
+ lines << text
228
+ lines << ""
229
+ end
230
+ rescue => e
231
+ lines << "## Controller Context"
232
+ lines << "_Could not load: #{e.message}_"
233
+ lines << ""
234
+ end
235
+ end
236
+ end
237
+
238
+ # File context
239
+ if file && line
240
+ begin
241
+ result = GetEditContext.call(file: file, near: parsed[:method_name] || line.to_s)
242
+ text = result.content.first[:text]
243
+ unless text.include?("not found") || text.include?("not allowed")
244
+ lines << "## Code Context"
245
+ lines << text
246
+ lines << ""
247
+ end
248
+ rescue => e
249
+ lines << "## Code Context"
250
+ lines << "_Could not load: #{e.message}_"
251
+ lines << ""
252
+ end
253
+ end
254
+
255
+ # Schema context for schema_mismatch errors
256
+ if classification[:type] == :schema_mismatch
257
+ # Try to extract table name from error
258
+ table = parsed[:message].match(/(?:table|relation)\s+["']?(\w+)["']?/i)&.[](1)
259
+ if table
260
+ begin
261
+ result = GetSchema.call(table: table)
262
+ text = result.content.first[:text]
263
+ unless text.include?("not found")
264
+ lines << "## Schema Context"
265
+ lines << text
266
+ lines << ""
267
+ end
268
+ rescue; end
269
+ end
270
+ end
271
+
272
+ # Model context for validation errors
273
+ if classification[:type] == :validation_failure
274
+ # Try to extract model from file path or error
275
+ model_name = if file&.match?(%r{app/models/(.+)\.rb})
276
+ file.match(%r{app/models/(.+)\.rb})[1].camelize
277
+ end
278
+ if model_name
279
+ begin
280
+ result = GetModelDetails.call(model: model_name)
281
+ text = result.content.first[:text]
282
+ unless text.include?("not found")
283
+ lines << "## Model Context"
284
+ lines << text
285
+ lines << ""
286
+ end
287
+ rescue; end
288
+ end
289
+ end
290
+
291
+ # Trace method if we know the method name
292
+ if parsed[:method_name] && lines.none? { |l| l.include?("Code Context") }
293
+ begin
294
+ result = SearchCode.call(pattern: parsed[:method_name], match_type: "trace")
295
+ text = result.content.first[:text]
296
+ unless text.include?("No results") || text.include?("No definition")
297
+ lines << "## Method Trace"
298
+ lines << text
299
+ lines << ""
300
+ end
301
+ rescue; end
302
+ end
303
+
304
+ lines
305
+ end
306
+
307
+ # Infer a specific diagnosis from the error + context
308
+ def infer_specific_cause(parsed, classification, file, action) # rubocop:disable Metrics
309
+ msg = parsed[:message].to_s
310
+ method = parsed[:method_name]
311
+
312
+ # "undefined method X for nil" — identify WHAT is nil
313
+ if classification[:type] == :nil_reference && msg.include?("for nil")
314
+ # Check if calling on current_user (common: auth not running)
315
+ if file&.include?("controller") && msg.match?(/current_user/)
316
+ return "`current_user` is nil — the `authenticate_user!` before_action may not be running for this route. " \
317
+ "Check if this action is excluded via `unless:` or `skip_before_action`."
318
+ end
319
+ # Check if calling on an association
320
+ if method && file
321
+ begin
322
+ ctx = GetEditContext.call(file: file, near: method)
323
+ code = ctx.content.first[:text]
324
+ # Find the receiver: something.method_name
325
+ receiver_match = code.match(/(\w+)\.#{Regexp.escape(method)}/)
326
+ if receiver_match
327
+ receiver = receiver_match[1]
328
+ return "`#{receiver}` is nil when `.#{method}` is called. " \
329
+ "This variable may not be set in all code paths — check if it's assigned before use, " \
330
+ "or use `#{receiver}&.#{method}` for safe navigation."
331
+ end
332
+ rescue; end
333
+ end
334
+ end
335
+
336
+ # RecordNotFound — check if there's a set_* before_action
337
+ if classification[:type] == :record_not_found && action
338
+ ctrl, act = action.split("#", 2)
339
+ if ctrl && act
340
+ begin
341
+ ctrl_class = ctrl.end_with?("Controller") ? ctrl : "#{ctrl.camelize}Controller"
342
+ result = GetControllers.call(controller: ctrl_class, action: act)
343
+ text = result.content.first[:text]
344
+ if text.include?("set_") && text.include?("find")
345
+ return "The `set_*` before_action uses `.find` which raises RecordNotFound. " \
346
+ "The record with the given ID doesn't exist or doesn't belong to the current user. " \
347
+ "Check if the record was deleted or if the user is authorized to access it."
348
+ end
349
+ rescue; end
350
+ end
351
+ end
352
+
353
+ nil
354
+ end
355
+
356
+ def gather_git_context(file, file_refs)
357
+ lines = []
358
+ root = Rails.root.to_s
359
+
360
+ files_to_check = [ file, *file_refs.map { |r| r[:file] } ].compact.uniq.first(3)
361
+ return lines if files_to_check.empty?
362
+
363
+ git_output = []
364
+ files_to_check.each do |f|
365
+ full = File.join(root, f)
366
+ next unless File.exist?(full)
367
+ output, status = Open3.capture2("git", "log", "--oneline", "-5", "--", f, chdir: root)
368
+ if status.success? && !output.strip.empty?
369
+ git_output << "**#{f}:**\n#{output.strip}"
370
+ end
371
+ end
372
+
373
+ if git_output.any?
374
+ lines << "## Recent Git Changes"
375
+ lines.concat(git_output)
376
+ lines << ""
377
+ end
378
+
379
+ lines
380
+ rescue
381
+ []
382
+ end
383
+
384
+ def gather_log_context(exception_class)
385
+ return [] unless exception_class
386
+
387
+ begin
388
+ result = ReadLogs.call(level: "ERROR", lines: 15, search: exception_class)
389
+ text = result.content.first[:text]
390
+ return [] if text.include?("Log file is empty") || text.include?("not found") || text.include?("No entries")
391
+
392
+ [ "## Recent Error Logs", text, "" ]
393
+ rescue
394
+ []
395
+ end
396
+ end
397
+
398
+ # Truncate the content of a named section (identified by "## heading") within a lines array.
399
+ # Returns a new array with the section's content lines truncated if they exceed max_chars.
400
+ def truncate_section(lines, heading, max_chars)
401
+ return lines if lines.empty? || max_chars.nil?
402
+
403
+ header_marker = "## #{heading}"
404
+ header_idx = lines.index(header_marker)
405
+ return lines unless header_idx
406
+
407
+ # Find the end of this section: next "## " header or end of array
408
+ section_end = nil
409
+ (header_idx + 1...lines.length).each do |i|
410
+ if lines[i].is_a?(String) && lines[i].start_with?("## ")
411
+ section_end = i
412
+ break
413
+ end
414
+ end
415
+ section_end ||= lines.length
416
+
417
+ # Measure content between header and section_end
418
+ content_lines = lines[(header_idx + 1)...section_end]
419
+ content = content_lines.join("\n")
420
+
421
+ return lines if content.length <= max_chars
422
+
423
+ # Truncate and rebuild
424
+ truncated_content = content[0, max_chars]
425
+ truncated_content += "\n\n_... section truncated (#{content.length} chars → #{max_chars} max)._"
426
+
427
+ result = lines[0...header_idx + 1]
428
+ result << truncated_content
429
+ result << ""
430
+ result.concat(lines[section_end..])
431
+ result
432
+ end
433
+ end
434
+ end
435
+ end
436
+ end