rails-ai-context 5.32.2 → 5.34.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.
Files changed (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1491 -0
  3. data/CONTEXT.md +27 -3
  4. data/README.md +16 -12
  5. data/Rakefile +6 -0
  6. data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
  7. data/config/routes.rb +2 -3
  8. data/docs/ARCHITECTURE.md +7 -5
  9. data/docs/CLI.md +79 -6
  10. data/docs/COMPATIBILITY.md +75 -1
  11. data/docs/CONFIGURATION.md +30 -11
  12. data/docs/CUSTOM_TOOLS.md +2 -1
  13. data/docs/FAQ.md +16 -6
  14. data/docs/GUIDE.md +117 -71
  15. data/docs/INTROSPECTORS.md +10 -8
  16. data/docs/QUICKSTART.md +3 -3
  17. data/docs/RECIPES.md +1 -1
  18. data/docs/SECURITY.md +109 -25
  19. data/docs/SETUP.md +30 -6
  20. data/docs/STANDALONE.md +10 -3
  21. data/docs/TOOLS.md +152 -34
  22. data/docs/TROUBLESHOOTING.md +90 -12
  23. data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
  24. data/docs/adr/0005-workspace-mcp-entries.md +39 -0
  25. data/exe/rails-ai-context +733 -104
  26. data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
  27. data/lib/rails_ai_context/app_kind.rb +18 -5
  28. data/lib/rails_ai_context/ast_cache.rb +7 -0
  29. data/lib/rails_ai_context/base_mixins.rb +8 -8
  30. data/lib/rails_ai_context/boot_manager.rb +22 -3
  31. data/lib/rails_ai_context/change_watch.rb +14 -11
  32. data/lib/rails_ai_context/cli/app_root.rb +401 -0
  33. data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
  34. data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
  35. data/lib/rails_ai_context/code_reloader.rb +115 -1
  36. data/lib/rails_ai_context/concern_membership.rb +3 -1
  37. data/lib/rails_ai_context/concern_paths.rb +8 -3
  38. data/lib/rails_ai_context/configuration.rb +41 -9
  39. data/lib/rails_ai_context/context_file_report.rb +19 -2
  40. data/lib/rails_ai_context/database_file.rb +26 -0
  41. data/lib/rails_ai_context/doctor.rb +1122 -184
  42. data/lib/rails_ai_context/engine.rb +18 -1
  43. data/lib/rails_ai_context/erb_source.rb +19 -0
  44. data/lib/rails_ai_context/executable.rb +21 -0
  45. data/lib/rails_ai_context/facts_formatter.rb +5 -2
  46. data/lib/rails_ai_context/file_walk.rb +61 -10
  47. data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
  48. data/lib/rails_ai_context/fingerprinter.rb +40 -12
  49. data/lib/rails_ai_context/gem_lock.rb +186 -27
  50. data/lib/rails_ai_context/git_ignore.rb +22 -0
  51. data/lib/rails_ai_context/install/ai_tool.rb +31 -5
  52. data/lib/rails_ai_context/install/cleanup.rb +21 -0
  53. data/lib/rails_ai_context/install/program.rb +209 -35
  54. data/lib/rails_ai_context/install/selection_record.rb +258 -97
  55. data/lib/rails_ai_context/install/surface.rb +6 -1
  56. data/lib/rails_ai_context/install/validation_hook.rb +282 -0
  57. data/lib/rails_ai_context/install/workspace.rb +166 -0
  58. data/lib/rails_ai_context/install_mode.rb +71 -13
  59. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
  60. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
  61. data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
  63. data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
  65. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
  66. data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
  67. data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
  68. data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
  69. data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
  70. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
  71. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
  72. data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
  73. data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
  74. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
  75. data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
  76. data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
  77. data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
  78. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
  79. data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
  80. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
  81. data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
  82. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
  83. data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
  84. data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
  85. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
  86. data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
  87. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
  88. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
  90. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
  91. data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
  92. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
  93. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
  94. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
  95. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
  96. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
  97. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
  98. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
  99. data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
  100. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
  101. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
  102. data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
  103. data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
  104. data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
  105. data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
  106. data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
  107. data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
  108. data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
  109. data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
  110. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
  111. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
  112. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
  113. data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
  114. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
  115. data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
  116. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
  117. data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
  118. data/lib/rails_ai_context/live_reload.rb +20 -20
  119. data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
  120. data/lib/rails_ai_context/mcp_edge.rb +182 -32
  121. data/lib/rails_ai_context/middleware.rb +5 -1
  122. data/lib/rails_ai_context/migration_status.rb +88 -25
  123. data/lib/rails_ai_context/output_guard.rb +48 -2
  124. data/lib/rails_ai_context/package_json.rb +1 -1
  125. data/lib/rails_ai_context/path_resolver.rb +46 -13
  126. data/lib/rails_ai_context/payload.rb +92 -8
  127. data/lib/rails_ai_context/pending_migrations.rb +5 -1
  128. data/lib/rails_ai_context/portable_path.rb +3 -2
  129. data/lib/rails_ai_context/presets.rb +15 -11
  130. data/lib/rails_ai_context/redaction.rb +262 -51
  131. data/lib/rails_ai_context/resources.rb +55 -20
  132. data/lib/rails_ai_context/route_coverage.rb +8 -0
  133. data/lib/rails_ai_context/safe_file.rb +14 -1
  134. data/lib/rails_ai_context/safe_path.rb +101 -4
  135. data/lib/rails_ai_context/schema_adapter.rb +8 -0
  136. data/lib/rails_ai_context/schema_version.rb +9 -0
  137. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
  138. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
  139. data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
  140. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
  141. data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
  142. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
  143. data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
  144. data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
  145. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
  146. data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
  147. data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
  148. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
  149. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
  150. data/lib/rails_ai_context/server.rb +304 -71
  151. data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
  152. data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
  153. data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
  154. data/lib/rails_ai_context/tools/base_tool.rb +367 -46
  155. data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
  156. data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
  157. data/lib/rails_ai_context/tools/diagnose.rb +218 -11
  158. data/lib/rails_ai_context/tools/generate_test.rb +339 -39
  159. data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
  160. data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
  161. data/lib/rails_ai_context/tools/get_concern.rb +91 -27
  162. data/lib/rails_ai_context/tools/get_config.rb +13 -4
  163. data/lib/rails_ai_context/tools/get_context.rb +71 -14
  164. data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
  165. data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
  166. data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
  167. data/lib/rails_ai_context/tools/get_env.rb +18 -9
  168. data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
  169. data/lib/rails_ai_context/tools/get_gems.rb +1 -1
  170. data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
  171. data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
  172. data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
  173. data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
  174. data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
  175. data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
  176. data/lib/rails_ai_context/tools/get_routes.rb +29 -8
  177. data/lib/rails_ai_context/tools/get_schema.rb +52 -19
  178. data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
  179. data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
  180. data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
  181. data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
  182. data/lib/rails_ai_context/tools/get_view.rb +14 -14
  183. data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
  184. data/lib/rails_ai_context/tools/onboard.rb +52 -8
  185. data/lib/rails_ai_context/tools/performance_check.rb +5 -3
  186. data/lib/rails_ai_context/tools/query.rb +914 -86
  187. data/lib/rails_ai_context/tools/read_logs.rb +69 -15
  188. data/lib/rails_ai_context/tools/review_changes.rb +215 -53
  189. data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
  190. data/lib/rails_ai_context/tools/safe_call.rb +35 -14
  191. data/lib/rails_ai_context/tools/search_code.rb +243 -67
  192. data/lib/rails_ai_context/tools/search_docs.rb +4 -4
  193. data/lib/rails_ai_context/tools/security_scan.rb +143 -33
  194. data/lib/rails_ai_context/tools/session_context.rb +39 -5
  195. data/lib/rails_ai_context/tools/validate.rb +259 -40
  196. data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
  197. data/lib/rails_ai_context/version.rb +1 -1
  198. data/lib/rails_ai_context/vfs.rb +67 -40
  199. data/lib/rails_ai_context/view_file.rb +41 -3
  200. data/lib/rails_ai_context/watcher.rb +51 -13
  201. data/lib/rails_ai_context.rb +21 -0
  202. data/server.json +1 -1
  203. metadata +14 -1
@@ -83,8 +83,8 @@ module RailsAiContext
83
83
  unresolved = unresolved.select { |name, _| subgraph.key?(name) }
84
84
  subgraph = subgraph.first(MAX_NODES).to_h if subgraph.size > MAX_NODES
85
85
 
86
- # Optional analyses
87
- cycles = show_cycles ? detect_cycles(graph) : []
86
+ # Optional analyses. nil when not asked, so a search that found none can say so.
87
+ cycles = show_cycles ? detect_cycles(graph) : nil
88
88
  sti_groups = show_sti ? extract_sti_groups(models_data) : []
89
89
  skipped = models_data.select { |_, data| data.is_a?(Hash) && data[:error] }.keys.map(&:to_s)
90
90
 
@@ -116,19 +116,19 @@ module RailsAiContext
116
116
  end
117
117
  end
118
118
 
119
- # Second pass: find concrete types for each polymorphic interface
119
+ # Second pass: a model implements an interface by declaring `as:` on
120
+ # it. The key's name says nothing: Tag's `has_many :products,
121
+ # through: :taggings, source: :taggable` reads the key taggable_id
122
+ # and implements nothing.
120
123
  models_data.each do |model_name, data|
121
124
  next unless data.is_a?(Hash) && !data[:error]
122
125
  (data[:associations] || []).each do |assoc|
123
126
  type = (assoc[:macro] || assoc[:type]).to_s
124
127
  next unless type == "has_many" || type == "has_one"
125
- # has_many :comments, as: :commentable → options[:as] stored as foreign_key pattern
126
- # The association's foreign_key will be "commentable_id" for `as: :commentable`
127
- fk = assoc[:foreign_key].to_s
128
- interface = fk.sub(/_id\z/, "")
129
- if polymorphic_interfaces.key?(interface)
130
- polymorphic_interfaces[interface] << model_name.to_s
131
- end
128
+ next if assoc[:through]
129
+
130
+ interface = polymorphic_as(assoc)
131
+ polymorphic_interfaces[interface] |= [ model_name.to_s ] if polymorphic_interfaces.key?(interface)
132
132
  end
133
133
  end
134
134
 
@@ -195,6 +195,15 @@ module RailsAiContext
195
195
  [ graph, unresolved ]
196
196
  end
197
197
 
198
+ # `as: :commentable` as each tier records it: the option itself
199
+ # statically, and its display text (":commentable") wherever the
200
+ # declaration was read.
201
+ def polymorphic_as(assoc)
202
+ options, declared = assoc.values_at(:options, :declared_options)
203
+ value = (options[:as] if options.is_a?(Hash)) || (declared["as"] if declared.is_a?(Hash))
204
+ value&.to_s&.delete_prefix(":")
205
+ end
206
+
198
207
  # How many of them the note spells out before it counts the rest.
199
208
  UNRESOLVED_NAMED = 10
200
209
 
@@ -291,8 +300,33 @@ module RailsAiContext
291
300
  subgraph
292
301
  end
293
302
 
294
- # DFS-based cycle detection. Returns array of cycle paths.
303
+ # A cycle is drawn over foreign keys, one edge per key, from the model
304
+ # whose table holds it to the model it points at. A belongs_to and the
305
+ # has_many on its other end are one key, so a pair of models joined
306
+ # both ways is not a cycle; a key to its own model is a tree, not one
307
+ # either. A through association and a join table hold no key of
308
+ # their own.
309
+ def foreign_key_edges(graph)
310
+ edges = Hash.new { |hash, key| hash[key] = Set.new }
311
+ graph.each do |model, model_edges|
312
+ model_edges.each do |edge|
313
+ next if edge[:through]
314
+
315
+ case edge[:type].to_s
316
+ when "belongs_to"
317
+ targets = edge[:polymorphic] ? Array(edge[:polymorphic_targets]) : [ edge[:target] ]
318
+ targets.each { |target| edges[model] << target unless target == model }
319
+ when "has_many", "has_one"
320
+ edges[edge[:target]] << model unless edge[:target] == model
321
+ end
322
+ end
323
+ end
324
+ edges
325
+ end
326
+
327
+ # DFS-based cycle detection over the foreign keys. Returns array of cycle paths.
295
328
  def detect_cycles(graph)
329
+ keys = foreign_key_edges(graph)
296
330
  cycles = []
297
331
  visited = Set.new
298
332
  in_stack = Set.new
@@ -304,8 +338,7 @@ module RailsAiContext
304
338
  in_stack.add(node)
305
339
  path.push(node)
306
340
 
307
- (graph[node] || []).each do |edge|
308
- target = edge[:target]
341
+ keys[node].sort.each do |target|
309
342
  if in_stack.include?(target)
310
343
  # Found cycle: extract from target's position in path
311
344
  cycle_start = path.index(target)
@@ -346,7 +379,18 @@ module RailsAiContext
346
379
  groups
347
380
  end
348
381
 
349
- def render_mermaid(graph, center, cycles: [], sti_groups: [], total_nodes: nil, total_edges: nil, skipped: [], unresolved: {})
382
+ CYCLES_NOTE = "_Chains of foreign keys that lead back to the model they start from, through another: a belongs_to, or the " \
383
+ "has_many or has_one at its other end. An association and its inverse are one key, and a key to the model's own " \
384
+ "table is a tree, so neither is a cycle._"
385
+
386
+ def cycle_lines(cycles)
387
+ return [] if cycles.nil?
388
+ return [ "## Circular Dependencies", "None. #{CYCLES_NOTE}" ] if cycles.empty?
389
+
390
+ [ "## Circular Dependencies", CYCLES_NOTE, *cycles.map { |c| "- #{c.join(" → ")} → #{c.first}" } ]
391
+ end
392
+
393
+ def render_mermaid(graph, center, cycles: nil, sti_groups: [], total_nodes: nil, total_edges: nil, skipped: [], unresolved: {})
350
394
  lines = [ "# Dependency Graph", "" ]
351
395
  lines << "```mermaid"
352
396
  lines << "graph LR"
@@ -423,18 +467,13 @@ module RailsAiContext
423
467
 
424
468
  stats = [ "**Models:** #{total_nodes || graph.keys.size}",
425
469
  "**Associations:** #{total_edges || graph.values.sum(&:size)}" ]
426
- stats << "**Cycles:** #{cycles.size}" if cycles.any?
470
+ stats << "**Cycles:** #{cycles.size}" if cycles
427
471
  stats << "**STI hierarchies:** #{sti_groups.size}" if sti_groups.any?
428
472
  lines << stats.join(" | ")
429
473
  lines.concat(truncation_notes(graph, total_nodes, skipped, total_edges, unresolved, drawn: arrows, focused: !center.nil?))
430
474
 
431
475
  # Cycles section
432
- if cycles.any?
433
- lines << ""
434
- lines << "## Circular Dependencies"
435
- cycles.each { |c| lines << "- #{c.join(" → ")} → #{c.first}" }
436
- end
437
-
476
+ lines.push("", *cycle_lines(cycles)) if cycles
438
477
  lines.join("\n")
439
478
  end
440
479
 
@@ -444,7 +483,7 @@ module RailsAiContext
444
483
  .group_by { |e| [ e[:type].to_s, e[:target] ] }.select { |_, list| list.size > 1 }.keys
445
484
  end
446
485
 
447
- def render_text(graph, center, cycles: [], sti_groups: [], total_nodes: nil, total_edges: nil, skipped: [], unresolved: {})
486
+ def render_text(graph, center, cycles: nil, sti_groups: [], total_nodes: nil, total_edges: nil, skipped: [], unresolved: {})
448
487
  lines = [ "# Dependency Graph", "" ]
449
488
  rows = 0
450
489
 
@@ -489,15 +528,11 @@ module RailsAiContext
489
528
  end
490
529
 
491
530
  # Cycles section
492
- if cycles.any?
493
- lines << "## Circular Dependencies"
494
- cycles.each { |c| lines << "- #{c.join(" → ")} → #{c.first}" }
495
- lines << ""
496
- end
531
+ lines.concat(cycle_lines(cycles)) << "" if cycles
497
532
 
498
533
  stats = [ "**Models:** #{total_nodes || graph.keys.size}",
499
534
  "**Associations:** #{total_edges || graph.values.sum(&:size)}" ]
500
- stats << "**Cycles:** #{cycles.size}" if cycles.any?
535
+ stats << "**Cycles:** #{cycles.size}" if cycles
501
536
  stats << "**STI hierarchies:** #{sti_groups.size}" if sti_groups.any?
502
537
  lines << stats.join(" | ")
503
538
  lines.concat(truncation_notes(graph, total_nodes, skipped, total_edges, unresolved, drawn: rows, focused: !center.nil?))
@@ -106,6 +106,17 @@ module RailsAiContext
106
106
  type: :argument_error,
107
107
  likely: "A method received wrong number or type of arguments.",
108
108
  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"
109
+ },
110
+ # The authorization gems' two failures, by the words their class names share.
111
+ /AuthorizationNotPerformed|PolicyScopingNotPerformed/ => {
112
+ type: :authorization_not_performed,
113
+ likely: "The action finished without authorizing, and a check that every action does raised: Pundit's `after_action :verify_authorized` (or `verify_policy_scoped`). The message names the controller.",
114
+ fix: "1. Call `authorize record` (or `policy_scope(Model)`) in the action\n2. If the action is meant to be open, leave it out of the check: `skip_after_action :verify_authorized, only: :action_name`\n3. Use `rails_get_controllers(controller:\"...\", action:\"...\")` to see the filters that run on the action"
115
+ },
116
+ /NotAuthorized|AccessDenied/ => {
117
+ type: :authorization_denied,
118
+ likely: "An authorization rule refused the current user this action on this record. Pundit's message names the policy method that returned false; CanCanCan's the action and subject.",
119
+ fix: "1. Read the policy method or ability rule the message names\n2. Check which user and record the request carried\n3. If refusing is expected, rescue it (`rescue_from`) and answer 403 instead of 500"
109
120
  }
110
121
  }.freeze
111
122
 
@@ -144,7 +155,7 @@ module RailsAiContext
144
155
  git_section = gather_git_context(file, parsed[:file_refs])
145
156
 
146
157
  # Recent error logs
147
- log_section = gather_log_context(parsed[:exception_class])
158
+ log_section = gather_log_context(parsed)
148
159
 
149
160
  # Truncate large sections before assembling final output
150
161
  context_sections = truncate_section(context_sections, "Controller Context", MAX_SECTION_CHARS[:controller_context])
@@ -179,11 +190,16 @@ module RailsAiContext
179
190
  output = output[0, MAX_TOTAL_OUTPUT] + "\n\n_... output truncated (#{output.length} chars exceeded #{MAX_TOTAL_OUTPUT} limit)._"
180
191
  end
181
192
 
182
- text_response(output)
193
+ # The diagnosis still answers the error, and the result is an error as every refusal is.
194
+ refusal ? error_response(output) : text_response(output)
183
195
  rescue => e
184
196
  text_response("Diagnosis error: #{e.message}")
185
197
  end
186
198
 
199
+ # A name an error message gives its exception: a class ending the way
200
+ # exceptions are named, or any namespaced constant.
201
+ EXCEPTION_CLASS = /[A-Z][\w:]*(?:Error|Exception|Invalid|NotFound|NotSaved|Missing)|[A-Z]\w*(?:::[A-Z]\w*)+/
202
+
187
203
  class << self
188
204
  private
189
205
 
@@ -191,12 +207,19 @@ module RailsAiContext
191
207
  result = { exception_class: nil, message: error_string.strip, file_refs: [], method_name: nil }
192
208
 
193
209
  # Extract exception class: "NoMethodError: ..." or "ActiveRecord::RecordNotFound ..."
210
+ first_line = error_string.strip.lines.first.to_s.strip
194
211
  if (m = error_string.match(/\A([\w:]+(?:Error|Exception|Invalid|NotFound|NotSaved|Missing))\s*[:—]\s*(.*)/m))
195
212
  result[:exception_class] = m[1]
196
213
  result[:message] = m[2].strip
197
214
  elsif (m = error_string.match(/\A([\w:]+::\w+)\s*[:—]\s*(.*)/m))
198
215
  result[:exception_class] = m[1]
199
216
  result[:message] = m[2].strip
217
+ elsif (m = first_line.match(/\A(#{EXCEPTION_CLASS}) \((.*)\):?\z/o))
218
+ # As a Rails log writes it: "Pundit::NotAuthorizedError (not allowed to ...):".
219
+ result[:exception_class] = m[1]
220
+ result[:message] = m[2].strip
221
+ elsif first_line.match?(/\A#{EXCEPTION_CLASS}\z/o)
222
+ result[:exception_class] = first_line
200
223
  end
201
224
 
202
225
  # Extract file:line references
@@ -249,27 +272,134 @@ module RailsAiContext
249
272
  model_specific = classify_undefined_method_on_model(parsed)
250
273
  return model_specific if model_specific
251
274
 
275
+ on_receiver = classify_method_on_receiver(parsed)
276
+ return on_receiver if on_receiver
277
+
252
278
  error_str = "#{parsed[:exception_class]} #{parsed[:message]}"
253
279
 
254
280
  ERROR_CLASSIFICATIONS.each do |pattern, info|
255
281
  return info if error_str.match?(pattern)
256
282
  end
257
283
 
284
+ unclassified(parsed[:exception_class])
285
+ end
286
+
287
+ # No rule here covers the class, so the answer says what it is and
288
+ # where it comes from, which is where reading starts.
289
+ def unclassified(exception_class)
290
+ origin = exception_origin(exception_class)
291
+ likely = if origin
292
+ "No rule here covers `#{exception_class}`, which #{origin}. Its message says what failed; " \
293
+ "what raises it, and why, is in that code."
294
+ else
295
+ "Unable to automatically classify this error. Review the full error message and stack trace."
296
+ end
258
297
  {
259
298
  type: :unknown,
260
- likely: "Unable to automatically classify this error. Review the full error message and stack trace.",
261
- 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"
299
+ likely: likely,
300
+ fix: "1. Check the error message for clues about what went wrong\n" \
301
+ "2. Use `rails_search_code(pattern:\"#{exception_class&.split("::")&.last || "ErrorName"}\")` to find where the app raises or rescues it\n" \
302
+ "3. Use `rails_read_logs(level:\"ERROR\")` for the backtrace"
303
+ }
304
+ end
305
+
306
+ # The Rails namespaces that ship in a gem named for none of them.
307
+ ACTIONPACK_NAMESPACES = %w[action_controller action_dispatch abstract_controller].to_h { |top| [ top, "actionpack" ] }.freeze
308
+
309
+ # Booted, Ruby knows the file that defines the class; statically, or
310
+ # for a class nothing has loaded yet, a locked gem named after the
311
+ # class's top namespace is the likely one.
312
+ def exception_origin(name)
313
+ return nil if name.to_s.empty?
314
+
315
+ unless RailsAiContext.static_tier?
316
+ # Resolved first: a constant still waiting on its autoload has no
317
+ # location yet. An app file that does not compile is never
318
+ # required for it (see EagerLoad).
319
+ path = Introspectors::EagerLoad.constant(name) && (Object.const_source_location(name) rescue nil)&.first
320
+ if path
321
+ spec = Gem.loaded_specs.values.find { |s| path.start_with?("#{s.full_gem_path}/") }
322
+ return "the #{spec.name} gem (#{spec.version}) defines" if spec
323
+
324
+ root = "#{rails_app.root}/"
325
+ return "this app defines in `#{path.delete_prefix(root)}`" if path.start_with?(root)
326
+ end
327
+ end
328
+
329
+ top = name.split("::").first.to_s.underscore
330
+ lock = RailsAiContext::GemLock.for(rails_app.root.to_s)
331
+ gem = [ ACTIONPACK_NAMESPACES[top], top, top.delete("_"), top.dasherize ].compact.uniq.find { |candidate| lock.present?(candidate) }
332
+ "is likely the #{gem} gem's (the app's bundle locks #{gem} #{lock.version(gem)})" if gem
333
+ rescue StandardError, ScriptError => e
334
+ RailsAiContext.debug_fail(e, nil, label: "exception_origin")
335
+ end
336
+
337
+ # Only a nil receiver makes a NoMethodError a nil reference. On any
338
+ # other object the method is missing from it - a typo, or the wrong
339
+ # object - and `&.` would hide the error rather than fix it.
340
+ NIL_RECEIVER = /\bfor nil\b/
341
+ VISIBILITY = /\A(private|protected) method [`'](\w+[?!=]?)['`] called for/
342
+
343
+ def classify_method_on_receiver(parsed)
344
+ message = parsed[:message].to_s
345
+ receiver = extract_receiver_class(message)
346
+ if (m = message.match(VISIBILITY))
347
+ return {
348
+ type: :non_public_method,
349
+ likely: "`#{m[2]}` is #{m[1]} on #{receiver ? "`#{receiver}`" : "the receiver"}, so it cannot be called from outside the object.",
350
+ fix: "1. Call it from inside the class, or make it public if it is part of the interface\n" \
351
+ "2. Use `rails_search_code(pattern:\"#{m[2]}\", match_type:\"definition\")` to see where it is defined and under which visibility"
352
+ }
353
+ end
354
+
355
+ name = parsed[:method_name]
356
+ return nil unless name && message.match?(/undefined method/) && message.match?(/ for /) && !message.match?(NIL_RECEIVER)
357
+
358
+ defined, suggestion = receiver_methods(receiver, name, class_receiver: message.match?(/for class |:Class\b/))
359
+ subject = receiver ? "`#{receiver}`" : "The receiver"
360
+ likely = if defined
361
+ "#{subject} defines `#{name}` in this process, so the object that raised was not the one you expect, " \
362
+ "or it ran on code loaded before the method was added: restart a long-running server."
363
+ else
364
+ "#{subject} has no method `#{name}`. The receiver is not nil, so this is no nil reference: " \
365
+ "the name is misspelled, or the method belongs to another object.#{" Did you mean `#{suggestion}`?" if suggestion}"
366
+ end
367
+ {
368
+ type: :undefined_method,
369
+ likely: likely,
370
+ fix: "1. #{suggestion ? "Call `#{suggestion}`, the method #{subject} has" : "Check the name against the methods #{subject} defines"}\n" \
371
+ "2. Check the call goes to the object you think: an association or a presenter can hand back another class\n" \
372
+ "3. Use `rails_search_code(pattern:\"#{name}\", match_type:\"trace\")` to find its definition and its callers"
262
373
  }
263
374
  end
264
375
 
376
+ # Booted, the loaded class answers whether it has the method, and the
377
+ # name it has that is closest to the one called. Statically there is
378
+ # no class to ask, and neither is there for a class whose file does
379
+ # not compile, which is never required to ask it (see EagerLoad).
380
+ def receiver_methods(receiver, name, class_receiver:)
381
+ return [ false, nil ] if receiver.nil? || RailsAiContext.static_tier?
382
+
383
+ klass = Introspectors::EagerLoad.constant(receiver)
384
+ return [ false, nil ] unless klass.is_a?(Module)
385
+
386
+ names = (class_receiver ? klass.public_methods : klass.public_instance_methods).map(&:to_s)
387
+ return [ true, nil ] if names.include?(name)
388
+
389
+ [ false, ::DidYouMean::SpellChecker.new(dictionary: names).correct(name).first ]
390
+ rescue StandardError, ScriptError => e
391
+ RailsAiContext.debug_fail(e, [ false, nil ], label: "receiver_methods")
392
+ end
393
+
265
394
  # Patterns that name the receiver of a NoMethodError:
266
395
  # "undefined method 'x' for an instance of Article" (Ruby 3.3+)
267
396
  # "undefined method `x' for #<Article id: 1>" (Ruby <= 3.2)
397
+ # "undefined method `x' for #<struct Summary ...>" (a Struct, <= 3.2)
268
398
  # "undefined method 'x' for class Article" (class receiver, 3.3+)
269
399
  # "undefined method `x' for Article:Class" (class receiver, <= 3.2)
270
400
  RECEIVER_PATTERNS = [
271
401
  /for an instance of ([A-Z]\w*(?:::\w+)*)/,
272
- /for #<([A-Z]\w*(?:::\w+)*)/,
402
+ /for #<(?:struct )?([A-Z]\w*(?:::\w+)*)/,
273
403
  /for class ([A-Z]\w*(?:::\w+)*)/,
274
404
  /for ([A-Z]\w*(?:::\w+)*):Class/
275
405
  ].freeze
@@ -508,19 +638,96 @@ module RailsAiContext
508
638
  status.success?
509
639
  end
510
640
 
511
- def gather_log_context(exception_class)
641
+ # The window rails_read_logs reads: the log's last megabyte. Fifteen
642
+ # lines, as this read before, rarely reached back to the error at all.
643
+ LOG_WINDOW = ReadLogs::MAX_READ_BYTES
644
+ # How far either side of the error line its request is looked for.
645
+ ENTRY_SPAN = 400
646
+ FRAMES_SHOWN = 5
647
+ LOG_TAG = /\A(\[[^\]]+\]) /
648
+ ANSI = /\e\[[\d;]*m/
649
+ REQUEST_LINE = /\A\s*(?:Started [A-Z]+ "|Processing by |Parameters: |Completed \d{3} )/
650
+ BACKTRACE_FRAME = /\S:\d+:in /
651
+
652
+ # The request that raised it: the log entry carrying the error, with
653
+ # the request's own lines (path, action, parameters, status) and the
654
+ # first frames of its backtrace, redacted.
655
+ def gather_log_context(parsed)
656
+ exception_class = parsed[:exception_class]
512
657
  return [] unless exception_class
513
658
 
659
+ located = RailsAiContext::SafePath.locate(File.join("log", "#{rails_env_name}.log"), under: rails_app.root.to_s, max_size: Float::INFINITY)
660
+ return [] unless located.ok?
661
+
662
+ lines = log_tail(located.realpath)
663
+ hit = lines.rindex { |line| log_match?(line, parsed) }
664
+ where = "the last #{count_phrase(lines.size, "line")} of `#{located.relative}`"
665
+ return [ "## Recent Error Logs", "_No entry for `#{exception_class}` in #{where}._", "" ] unless hit
666
+
667
+ tag, entry = log_entry(lines, hit)
668
+ request = tag ? ", request `#{tag[1..-2]}`" : ""
514
669
  begin
515
- result = ReadLogs.call(level: "ERROR", lines: 15, search: exception_class)
516
- return [] if empty?(result)
670
+ redacted = RailsAiContext::Redaction.redact_log_lines(entry)
671
+ rescue StandardError => e
672
+ # Never shown unredacted: the entry is named, not printed.
673
+ return [ "## Recent Error Logs", "_An entry for it#{request} is in #{where}, not shown: redacting it failed (#{e.class})._", "" ]
674
+ end
675
+ [ "## Recent Error Logs", "_The latest entry for it in #{where}#{request}:_", "```", *redacted, "```", "" ]
676
+ rescue => e
677
+ RailsAiContext.debug_fail(e, [], label: "gather_log_context")
678
+ end
517
679
 
518
- [ "## Recent Error Logs", response_text(result), "" ]
519
- rescue => e
520
- RailsAiContext.debug_fail(e, [], label: "gather_log_context")
680
+ def log_tail(path)
681
+ File.open(path, "rb") do |file|
682
+ cut = file.size > LOG_WINDOW
683
+ file.seek(-LOG_WINDOW, IO::SEEK_END) if cut
684
+ lines = file.read.force_encoding("UTF-8").scrub("?").split("\n")
685
+ # The window's first line starts mid-line.
686
+ cut ? lines.drop(1) : lines
521
687
  end
522
688
  end
523
689
 
690
+ # A line naming the class, and the method or the message too when the
691
+ # error gives one, whichever quotes the Ruby that wrote it used.
692
+ def log_match?(line, parsed)
693
+ return false unless line.include?(parsed[:exception_class])
694
+ return line.match?(/[`']#{Regexp.escape(parsed[:method_name])}'/) if parsed[:method_name]
695
+
696
+ key = loose(parsed[:message])[0, 60]
697
+ key == parsed[:exception_class] || loose(line).include?(key)
698
+ end
699
+
700
+ def loose(text)
701
+ text.to_s.tr("`", "'").gsub(/\s+/, " ").strip
702
+ end
703
+
704
+ # The error line's request: its request-id tag when the log writes one,
705
+ # else the nearest "Started" line above it.
706
+ def log_entry(lines, index)
707
+ tag = lines[index][LOG_TAG, 1]
708
+ own = ->(line) { tag.nil? || line.start_with?(tag) }
709
+ text = ->(line) { (tag && line.start_with?(tag) ? line.delete_prefix(tag).delete_prefix(" ") : line).gsub(ANSI, "") }
710
+
711
+ from = [ index - ENTRY_SPAN, 0 ].max
712
+ started = (from...index).reverse_each.find { |i| own.call(lines[i]) && text.call(lines[i]).match?(/\AStarted [A-Z]+ "/) }
713
+ request = started ? (started...index).select { |i| own.call(lines[i]) && text.call(lines[i]).match?(REQUEST_LINE) } : []
714
+
715
+ frames = []
716
+ ((index + 1)...[ index + ENTRY_SPAN, lines.size ].min).each do |i|
717
+ # A logger tags a message's first line only, so an untagged line
718
+ # continues the one above it.
719
+ next unless own.call(lines[i]) || !lines[i].match?(LOG_TAG)
720
+
721
+ line = text.call(lines[i])
722
+ next if line.strip.empty?
723
+ break unless line.match?(BACKTRACE_FRAME) && frames.size < FRAMES_SHOWN
724
+
725
+ frames << i
726
+ end
727
+
728
+ [ tag, (request + [ index ] + frames).map { |i| text.call(lines[i]).rstrip } ]
729
+ end
730
+
524
731
  # Truncate the content of a named section (identified by "## heading") within a lines array.
525
732
  # Returns a new array with the section's content lines truncated if they exceed max_chars.
526
733
  def truncate_section(lines, heading, max_chars)