rails-ai-context 5.35.0 → 5.35.2

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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1024 -0
  3. data/CONTEXT.md +37 -7
  4. data/CONTRIBUTING.md +30 -1
  5. data/README.md +9 -9
  6. data/SECURITY.md +2 -2
  7. data/docs/ARCHITECTURE.md +12 -4
  8. data/docs/CLI.md +44 -15
  9. data/docs/COMPATIBILITY.md +34 -8
  10. data/docs/CONFIGURATION.md +28 -5
  11. data/docs/CUSTOM_TOOLS.md +8 -2
  12. data/docs/FAQ.md +5 -5
  13. data/docs/GUIDE.md +33 -65
  14. data/docs/INTROSPECTORS.md +4 -4
  15. data/docs/QUICKSTART.md +1 -1
  16. data/docs/RECIPES.md +5 -5
  17. data/docs/RELEASING.md +3 -1
  18. data/docs/SECURITY.md +255 -74
  19. data/docs/SETUP.md +1 -1
  20. data/docs/STANDALONE.md +2 -2
  21. data/docs/TOOLS.md +151 -90
  22. data/docs/TROUBLESHOOTING.md +123 -8
  23. data/docs/_config.yml +1 -1
  24. data/docs/adr/0008-query-runs-only-where-it-can-guard.md +38 -0
  25. data/docs/adr/0009-dbhub-shapes-in-ruby.md +26 -0
  26. data/docs/adr/0010-one-config-tool-with-sections.md +32 -0
  27. data/docs/index.md +2 -2
  28. data/exe/rails-ai-context +24 -9
  29. data/lib/generators/rails_ai_context/install/install_generator.rb +2 -0
  30. data/lib/rails_ai_context/app_databases.rb +377 -0
  31. data/lib/rails_ai_context/app_kind.rb +2 -0
  32. data/lib/rails_ai_context/cli/app_root.rb +1 -0
  33. data/lib/rails_ai_context/cli/tool_runner.rb +110 -99
  34. data/lib/rails_ai_context/configuration.rb +15 -8
  35. data/lib/rails_ai_context/database_family/mysql_runtime.rb +170 -0
  36. data/lib/rails_ai_context/database_family/postgresql_plan.rb +124 -0
  37. data/lib/rails_ai_context/database_family/postgresql_runtime.rb +211 -0
  38. data/lib/rails_ai_context/database_family/sqlite_runtime.rb +391 -0
  39. data/lib/rails_ai_context/database_family.rb +282 -0
  40. data/lib/rails_ai_context/database_file.rb +61 -7
  41. data/lib/rails_ai_context/database_yml.rb +7 -29
  42. data/lib/rails_ai_context/doctor.rb +15 -16
  43. data/lib/rails_ai_context/instrumentation.rb +1 -1
  44. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +8 -23
  45. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +6 -70
  46. data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
  47. data/lib/rails_ai_context/introspectors/env_introspector.rb +1 -1
  48. data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
  49. data/lib/rails_ai_context/introspectors/job_introspector.rb +7 -7
  50. data/lib/rails_ai_context/introspectors/migration_introspector.rb +8 -11
  51. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +9 -9
  52. data/lib/rails_ai_context/introspectors/model_introspector.rb +33 -39
  53. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +33 -30
  54. data/lib/rails_ai_context/introspectors/node_source.rb +2 -0
  55. data/lib/rails_ai_context/introspectors/performance_introspector.rb +80 -24
  56. data/lib/rails_ai_context/introspectors/pg_partitions.rb +1 -4
  57. data/lib/rails_ai_context/introspectors/schema_conventions.rb +6 -8
  58. data/lib/rails_ai_context/introspectors/schema_dump_path.rb +12 -14
  59. data/lib/rails_ai_context/introspectors/schema_introspector.rb +47 -27
  60. data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -1
  61. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -4
  62. data/lib/rails_ai_context/migration_status.rb +18 -46
  63. data/lib/rails_ai_context/payload.rb +48 -1
  64. data/lib/rails_ai_context/pending_migrations.rb +3 -4
  65. data/lib/rails_ai_context/run_cache.rb +5 -0
  66. data/lib/rails_ai_context/schema_adapter.rb +13 -18
  67. data/lib/rails_ai_context/secret_columns.rb +506 -0
  68. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
  69. data/lib/rails_ai_context/serializers/markdown_serializer.rb +14 -10
  70. data/lib/rails_ai_context/serializers/section_facts.rb +12 -2
  71. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +5 -2
  72. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +35 -7
  73. data/lib/rails_ai_context/server.rb +143 -29
  74. data/lib/rails_ai_context/sql_gate.rb +1007 -0
  75. data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -2
  76. data/lib/rails_ai_context/test_helper.rb +3 -1
  77. data/lib/rails_ai_context/tools/analyze_feature.rb +11 -7
  78. data/lib/rails_ai_context/tools/base_tool.rb +71 -48
  79. data/lib/rails_ai_context/tools/dependency_graph.rb +1 -1
  80. data/lib/rails_ai_context/tools/diagnose.rb +105 -42
  81. data/lib/rails_ai_context/tools/generate_test.rb +4 -6
  82. data/lib/rails_ai_context/tools/get_active_support.rb +1 -6
  83. data/lib/rails_ai_context/tools/get_api.rb +1 -6
  84. data/lib/rails_ai_context/tools/get_autoload.rb +1 -6
  85. data/lib/rails_ai_context/tools/get_callbacks.rb +5 -5
  86. data/lib/rails_ai_context/tools/get_component_catalog.rb +6 -7
  87. data/lib/rails_ai_context/tools/get_concern.rb +6 -6
  88. data/lib/rails_ai_context/tools/get_config.rb +189 -12
  89. data/lib/rails_ai_context/tools/get_context.rb +111 -105
  90. data/lib/rails_ai_context/tools/get_controllers.rb +8 -5
  91. data/lib/rails_ai_context/tools/get_conventions.rb +1 -6
  92. data/lib/rails_ai_context/tools/get_engines.rb +1 -6
  93. data/lib/rails_ai_context/tools/get_env.rb +1 -6
  94. data/lib/rails_ai_context/tools/get_env_config.rb +2 -8
  95. data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -6
  96. data/lib/rails_ai_context/tools/get_gems.rb +1 -6
  97. data/lib/rails_ai_context/tools/get_helper_methods.rb +4 -5
  98. data/lib/rails_ai_context/tools/get_i18n.rb +1 -7
  99. data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
  100. data/lib/rails_ai_context/tools/get_mailers.rb +1 -1
  101. data/lib/rails_ai_context/tools/get_model_details.rb +5 -8
  102. data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -4
  103. data/lib/rails_ai_context/tools/get_schema.rb +10 -8
  104. data/lib/rails_ai_context/tools/get_service_pattern.rb +60 -24
  105. data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -1
  106. data/lib/rails_ai_context/tools/migration_advisor.rb +47 -32
  107. data/lib/rails_ai_context/tools/onboard.rb +24 -24
  108. data/lib/rails_ai_context/tools/performance_check.rb +15 -2
  109. data/lib/rails_ai_context/tools/query.rb +142 -1356
  110. data/lib/rails_ai_context/tools/read_logs.rb +1 -1
  111. data/lib/rails_ai_context/tools/review_changes.rb +6 -7
  112. data/lib/rails_ai_context/tools/runtime_info.rb +125 -88
  113. data/lib/rails_ai_context/tools/safe_call.rb +70 -28
  114. data/lib/rails_ai_context/tools/search_code.rb +0 -4
  115. data/lib/rails_ai_context/tools/search_docs.rb +3 -1
  116. data/lib/rails_ai_context/tools/security_scan.rb +9 -5
  117. data/lib/rails_ai_context/tools/session_context.rb +18 -12
  118. data/lib/rails_ai_context/tools/validate.rb +1 -0
  119. data/lib/rails_ai_context/tools/validate_semantics.rb +5 -5
  120. data/lib/rails_ai_context/version.rb +1 -1
  121. data/lib/rails_ai_context.rb +4 -7
  122. data/server.json +2 -2
  123. metadata +14 -3
@@ -235,8 +235,7 @@ module RailsAiContext::RakeTasks
235
235
  json_mode = ENV["JSON"] == "1"
236
236
 
237
237
  if params.delete(:help) || ARGV.include?("--help")
238
- runner = RailsAiContext::CLI::ToolRunner.new(name, {})
239
- puts RailsAiContext::CLI::ToolRunner.tool_help(runner.tool_class)
238
+ puts RailsAiContext::CLI::ToolRunner.new(name, {}).help
240
239
  next
241
240
  end
242
241
 
@@ -97,12 +97,14 @@ module RailsAiContext
97
97
  return name_or_class if name_or_class.is_a?(Class) && name_or_class < MCP::Tool
98
98
 
99
99
  name = name_or_class.to_s
100
- all_tools.find do |t|
100
+ found = all_tools.find do |t|
101
101
  t.tool_name == name ||
102
102
  t.tool_name == "rails_#{name}" ||
103
103
  t.tool_name == "rails_get_#{name}" ||
104
104
  t.tool_name == "get_#{name}"
105
105
  end
106
+ found || RailsAiContext::CLI::ToolRunner.candidate_names(name)
107
+ .filter_map { |candidate| RailsAiContext::Tools::GetConfig.retired_section(candidate) }.first&.renderer
106
108
  end
107
109
 
108
110
  def all_tools
@@ -96,18 +96,22 @@ module RailsAiContext
96
96
  # If nothing was discovered, return a clean "no match" with real suggestions
97
97
  has_content = lines.any? { |l| l.start_with?("## ") || l.start_with?("### ") }
98
98
  unless has_content
99
- model_names = Payload.models(ctx).keys.map(&:to_s).sort.first(10)
100
- hint = if model_names.any?
101
- "\n\nTry one of your model names: #{model_names.join(', ')}"
102
- else
103
- "\n\nThis app has no models yet, so there are no feature names to suggest."
104
- end
105
- return text_response("No matches found for '#{echo_input(feature)}'. No models, controllers, routes, services, or views match this keyword.#{hint}")
99
+ return text_response("No matches found for '#{echo_input(feature)}'. No models, controllers, routes, services, or views match this keyword.#{model_names_hint(ctx)}")
106
100
  end
107
101
 
108
102
  text_response(lines.join("\n"))
109
103
  end
110
104
 
105
+ # The names a keyword that matched nothing could have been.
106
+ def self.model_names_hint(ctx)
107
+ model_names = Payload.models(ctx).keys.map(&:to_s).sort.first(10)
108
+ if model_names.any?
109
+ "\n\nTry one of your model names: #{model_names.join(', ')}"
110
+ else
111
+ "\n\nThis app has no models yet, so there are no feature names to suggest."
112
+ end
113
+ end
114
+
111
115
  class << self
112
116
  private
113
117
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
3
4
  require "did_you_mean"
4
5
 
5
6
  require "mcp"
@@ -48,6 +49,24 @@ module RailsAiContext
48
49
  @abstract == true
49
50
  end
50
51
 
52
+ # A class that renders one rails_get_config section: called through
53
+ # that tool, and so never registered under its own name.
54
+ def section_renderer!
55
+ abstract!
56
+ end
57
+
58
+ # The class in Tools a tool name spells, or nil.
59
+ def tool_class(name)
60
+ const = name.to_s.delete_prefix("rails_").split("_").map(&:capitalize).join
61
+ Tools.const_get(const, false) if const.match?(/\A[A-Z]\w*\z/) && Tools.const_defined?(const, false)
62
+ end
63
+
64
+ # Whether the app serves this built-in itself. A custom tool under its
65
+ # name takes other parameters, so no answer runs it or points at it.
66
+ def own_served?(tool)
67
+ !tool.nil? && RailsAiContext::Server.served_tool(tool.tool_name).equal?(tool)
68
+ end
69
+
51
70
  # Sorted so load order never reaches tool --list or tools/list.
52
71
  def registered_tools
53
72
  eager_load!
@@ -70,6 +89,9 @@ module RailsAiContext
70
89
  # for thread safety in multi-threaded servers (e.g., Puma).
71
90
  SHARED_CACHE = { mutex: Mutex.new }
72
91
 
92
+ # Per thread, one list per running tool call of the composed tools it left out.
93
+ LEFT_OUT = :rails_ai_context_left_out
94
+
73
95
  # What a server last saw of its app's files (a Fingerprinter::Snapshot),
74
96
  # when the check now running began, and when the last finished one
75
97
  # did. The snapshot is nil until a server starts, so a process that is
@@ -240,7 +262,9 @@ module RailsAiContext
240
262
  # 30 at 10,000 files. Only a check that began after a call did can
241
263
  # stand in for that call's own, so two calls one after the other each
242
264
  # check, and concurrent calls wait for one check together. A tool
243
- # another tool calls is part of the first call and asks nothing.
265
+ # another tool calls is part of the first call, and SafeCall asks
266
+ # nothing for it. What a run already open read is dropped with the
267
+ # check, since it may be from before the edit.
244
268
  #
245
269
  # Outside SHARED_CACHE's mutex, unlike the TTL walk: a code reload
246
270
  # waits for running calls to finish, and they may be waiting on it.
@@ -251,10 +275,10 @@ module RailsAiContext
251
275
  # notes the files whose reload Bootsnap answers with their code from
252
276
  # before the last write.
253
277
  def refresh_if_files_changed!
254
- return if RunCache.active?
278
+ return unless FILE_CHECK[:snapshot]
255
279
 
256
280
  snapshot = await_check(Process.clock_gettime(Process::CLOCK_MONOTONIC))
257
- return unless snapshot
281
+ return RunCache.clear unless snapshot
258
282
 
259
283
  begin
260
284
  changed = snapshot.changed?
@@ -266,6 +290,7 @@ module RailsAiContext
266
290
  FILE_CHECK[:running] = nil
267
291
  FILE_CHECK[:done].broadcast
268
292
  end
293
+ RunCache.clear
269
294
  end
270
295
  rescue StandardError => e
271
296
  RailsAiContext.debug_fail(e, nil, label: "refresh_if_files_changed!")
@@ -980,8 +1005,7 @@ module RailsAiContext
980
1005
 
981
1006
  # Whether this tool's `detail` is DetailLevel's, read off the schema it
982
1007
  # already publishes rather than a second list someone has to keep in
983
- # step. `rails_onboard` spells its own levels (quick/standard/full) and
984
- # is deliberately not covered.
1008
+ # step. A custom tool that spells its own levels is left alone.
985
1009
  def detail_param?
986
1010
  return @detail_param if defined?(@detail_param)
987
1011
 
@@ -1026,7 +1050,6 @@ module RailsAiContext
1026
1050
  def text_response(text, suffix: nil)
1027
1051
  suffix = [ suffix, static_tier_banner, stale_code_banner ].compact.join
1028
1052
  suffix = nil if suffix.empty?
1029
- text = served_hints(text)
1030
1053
 
1031
1054
  record_call(text)
1032
1055
 
@@ -1049,53 +1072,53 @@ module RailsAiContext
1049
1072
  MCP_CALL = /\b(rails_\w+)\(([^()]*)\)/
1050
1073
  PARAM_HINT = /`((?:\w+:(?:"[^"`]*"|[\w.]+)(?:,\s*)?)+)`/
1051
1074
 
1052
- # A `_Next:` hint points at other tools, one pointer per tool, joined
1053
- # by ` | ` on one line or onto lines of their own. A tool skip_tools
1054
- # turned off is no next step, so its pointer goes, and a hint left
1055
- # with none goes whole. Every answer passes here, so no tool has to
1056
- # check the served set itself.
1057
- def served_hints(text)
1058
- return text unless text.is_a?(String) && text.include?("_Next: ")
1059
-
1060
- skipped = RailsAiContext::Server.skipped_tools
1061
- return text if skipped.empty?
1062
-
1063
- lines = text.split("\n", -1)
1064
- kept = []
1065
- index = 0
1066
- while index < lines.size
1067
- block = [ lines[index] ]
1068
- if block.first.start_with?("_Next: ")
1069
- block << lines[index += 1] while lines[index + 1]&.start_with?(" | ")
1070
- hint = served_hint(block, skipped)
1071
- if hint
1072
- kept.concat(hint)
1073
- elsif kept.last == "" && lines[index + 1].to_s.empty?
1074
- kept.pop
1075
- end
1076
- else
1077
- kept << block.first
1078
- end
1079
- index += 1
1080
- end
1081
- kept.join("\n")
1082
- rescue StandardError => e
1083
- RailsAiContext.debug_fail(e, text, label: "served_hints")
1075
+ # A call to another tool as an answer writes it, then what it is for; nil when the app does not
1076
+ # serve the gem's own tool or config section, so the hint is left out. A section goes by its retired name.
1077
+ def pointer(name, args = nil, purpose = nil)
1078
+ section = GetConfig.retired_section(name)
1079
+ args = [ %(section:"#{section.name}"), args ].compact.reject(&:empty?).join(", ") if section
1080
+ section ||= GetConfig.section_named("app") if name == "rails_get_config"
1081
+ return nil unless section ? section.served? : own_served?(tool_class(name))
1082
+
1083
+ [ "`#{section ? 'rails_get_config' : name}(#{args})`", purpose ].compact.join(" ")
1084
+ end
1085
+
1086
+ # A section of this answer that another tool builds. A tool the app does not
1087
+ # serve gives an empty one, and SafeCall names it once at the end of this answer.
1088
+ def compose(tool, **args)
1089
+ return tool.call(**args) if own_served?(tool)
1090
+
1091
+ Thread.current[BaseTool::LEFT_OUT]&.last&.push(tool.tool_name)
1092
+ MCP::Tool::Response.new([ { type: "text", text: "" } ], meta: { empty: true })
1093
+ end
1094
+
1095
+ def left_out_note(names)
1096
+ return nil if names.empty?
1097
+
1098
+ many = names.size > 1
1099
+ "\n\n_#{names.map { |n| "`#{n}`" }.join(', ')} #{many ? 'are' : 'is'} turned off in this app " \
1100
+ "(skip_tools lists #{many ? 'them' : 'it'}), so #{many ? 'their sections are' : 'its section is'} left out._"
1101
+ end
1102
+
1103
+ # "Call `<pointer>`" for a not-found answer's recovery line; nil when the tool is not served.
1104
+ def call_pointer(name, args = nil, purpose = nil)
1105
+ pointer(name, args, purpose)&.then { |call| "Call #{call}" }
1106
+ end
1107
+
1108
+ # The lines, with a blank line and the next-step hint after them when a pointer is served.
1109
+ def add_next_hint(lines, *pointers, **layout)
1110
+ hint = next_hint(*pointers, **layout)
1111
+ hint ? lines << "" << hint : lines
1084
1112
  end
1085
1113
 
1086
- # The hint's lines with the pointers to skipped tools taken out; nil
1087
- # when none is left.
1088
- def served_hint(block, skipped)
1089
- body = block.join("\n").delete_prefix("_Next: ")
1090
- closing = body[/\.?_\z/].to_s
1091
- pointers = body.delete_suffix(closing).split(/\n? \| /)
1092
- served = pointers.reject { |pointer| skipped.include?(pointer[/\brails_\w+/]) }
1093
- return block if served.size == pointers.size
1114
+ # `_Next:` with the pointers that are served, on one line or one per
1115
+ # line; nil when none is.
1116
+ def next_hint(*pointers, lines: false)
1117
+ served = pointers.compact
1094
1118
  return nil if served.empty?
1095
1119
 
1096
- "_Next: #{served.join(block.size > 1 ? "\n | " : " | ")}#{closing}".split("\n")
1120
+ "_Next: #{served.join(lines ? "\n | " : " | ")}_"
1097
1121
  end
1098
- private :served_hint
1099
1122
 
1100
1123
  def cli_form(text)
1101
1124
  return text unless RailsAiContext.configuration.tool_mode == :cli && text.is_a?(String)
@@ -38,7 +38,7 @@ module RailsAiContext
38
38
  )
39
39
 
40
40
  guide_row(
41
- order: 27,
41
+ order: 24,
42
42
  mcp: "rails_dependency_graph(model:\"X\")",
43
43
  cli_args: "model=X",
44
44
  summary: "Model association graph as Mermaid diagram"
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "did_you_mean"
3
4
  require "open3"
4
5
 
5
6
  module RailsAiContext
@@ -34,7 +35,7 @@ module RailsAiContext
34
35
  )
35
36
 
36
37
  guide_row(
37
- order: 35,
38
+ order: 30,
38
39
  mcp: "rails_diagnose(error:\"X\")",
39
40
  cli_args: "error=\"X\"",
40
41
  summary: "One-call error diagnosis: context + git changes + logs + fix suggestions"
@@ -60,63 +61,110 @@ module RailsAiContext
60
61
  /NameError.*uninitialized constant/ => {
61
62
  type: :name_error,
62
63
  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.",
63
- 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"
64
+ fix: [
65
+ "Check for typo in class/module name, missing require, or autoload issue",
66
+ "Verify the file is in the correct autoload path (e.g. app/models, app/services)",
67
+ { tool: "rails_search_code", args: %(pattern:"ClassName", match_type:"trace"), text: "Run %s to find where the constant is defined" }
68
+ ]
64
69
  },
65
70
  /NoMethodError/ => {
66
71
  type: :nil_reference,
67
72
  likely: "A method was called on nil or an undefined name was referenced. Check for: missing association, unfetched record, typo in method name.",
68
- 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"
73
+ fix: [
74
+ "Check the variable/object is not nil before calling the method",
75
+ "Verify the association or attribute exists on the model",
76
+ "Use `&.` safe navigation if the value can legitimately be nil"
77
+ ]
69
78
  },
70
79
  /NameError/ => {
71
80
  type: :name_error,
72
81
  likely: "An undefined name was referenced. This could be a typo in a variable, method, or constant name, or a missing require/autoload.",
73
- 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"
82
+ fix: [
83
+ "Check for typo in class/module name, missing require, or autoload issue",
84
+ "Verify the name is defined and accessible in the current scope",
85
+ { tool: "rails_search_code", args: %(pattern:"name", match_type:"trace"), text: "Use %s to find where it is defined" }
86
+ ]
74
87
  },
75
88
  /RecordNotFound/ => {
76
89
  type: :record_not_found,
77
90
  likely: "A `.find()` or `.find_by!()` call failed because the record doesn't exist. Common causes: stale URL, deleted record, wrong ID parameter.",
78
- 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"
91
+ fix: [
92
+ "Use `.find_by` (returns nil) instead of `.find` (raises)",
93
+ "Add a rescue handler or `before_action :set_record` with proper error handling",
94
+ "Check the route parameter name matches what the controller expects"
95
+ ]
79
96
  },
80
97
  /RecordInvalid|RecordNotSaved/ => {
81
98
  type: :validation_failure,
82
99
  likely: "A `.save!`, `.create!`, or `.update!` call failed validation. The model has validations that the submitted data doesn't satisfy.",
83
- 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"
100
+ fix: [
101
+ "Use `.save` (returns false) instead of `.save!` (raises) for user-facing flows",
102
+ "Check model validations against the params being submitted",
103
+ "Verify required associations exist before saving"
104
+ ]
84
105
  },
85
106
  /RoutingError/ => {
86
107
  type: :routing,
87
108
  likely: "No route matches the request. The URL or HTTP verb doesn't match any defined route.",
88
- 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"
109
+ fix: [
110
+ { tool: "rails_get_routes", args: nil, text: "Run %s to see all defined routes" },
111
+ "Check the HTTP verb (GET vs POST vs PATCH) matches",
112
+ "Verify the route is not restricted by constraints"
113
+ ]
89
114
  },
90
115
  /ParameterMissing/ => {
91
116
  type: :strong_params,
92
117
  likely: "A required parameter is missing from the request. The `params.require(:key)` call didn't find the expected key.",
93
- 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"
118
+ fix: [
119
+ "Check the form field names match what strong params expects",
120
+ "Verify the parameter nesting (e.g., `params[:user][:name]` vs `params[:name]`)",
121
+ "Check the controller's `*_params` method"
122
+ ]
94
123
  },
95
124
  /StatementInvalid|UndefinedColumn|UndefinedTable/ => {
96
125
  type: :schema_mismatch,
97
126
  likely: "A database query references a column or table that doesn't exist. Common after migrations or in stale schema.",
98
- 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"
127
+ fix: [
128
+ "Run `rails db:migrate` to apply pending migrations",
129
+ { tool: "rails_get_schema", args: %(table:"..."), text: "Check %s for actual column names" },
130
+ "Verify the migration was generated correctly"
131
+ ]
99
132
  },
100
133
  /Template::Error|ActionView/ => {
101
134
  type: :view_error,
102
135
  likely: "An error occurred while rendering a view template. The underlying error is usually a NoMethodError or missing local variable.",
103
- 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"
136
+ fix: [
137
+ "Check the instance variables set in the controller action",
138
+ "Verify partial locals are passed correctly",
139
+ { tool: "rails_get_view", args: %(controller:"..."), text: "Use %s to see template structure" }
140
+ ]
104
141
  },
105
142
  /ArgumentError/ => {
106
143
  type: :argument_error,
107
144
  likely: "A method received wrong number or type of arguments.",
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"
145
+ fix: [
146
+ "Check the method signature matches the call site",
147
+ { tool: "rails_search_code", args: %(pattern:"method_name", match_type:"trace"), text: "Use %s to see definition and callers" }
148
+ ]
109
149
  },
110
150
  # The authorization gems' two failures, by the words their class names share.
111
151
  /AuthorizationNotPerformed|PolicyScopingNotPerformed/ => {
112
152
  type: :authorization_not_performed,
113
153
  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"
154
+ fix: [
155
+ "Call `authorize record` (or `policy_scope(Model)`) in the action",
156
+ "If the action is meant to be open, leave it out of the check: `skip_after_action :verify_authorized, only: :action_name`",
157
+ { tool: "rails_get_controllers", args: %(controller:"...", action:"..."), text: "Use %s to see the filters that run on the action" }
158
+ ]
115
159
  },
116
160
  /NotAuthorized|AccessDenied/ => {
117
161
  type: :authorization_denied,
118
162
  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"
163
+ fix: [
164
+ "Read the policy method or ability rule the message names",
165
+ "Check which user and record the request carried",
166
+ "If refusing is expected, rescue it (`rescue_from`) and answer 403 instead of 500"
167
+ ]
120
168
  }
121
169
  }.freeze
122
170
 
@@ -144,7 +192,7 @@ module RailsAiContext
144
192
 
145
193
  # Suggested fix
146
194
  lines << "## Suggested Fix"
147
- lines << classification[:fix]
195
+ lines << fix_steps(classification[:fix])
148
196
  lines << ""
149
197
 
150
198
  # Gather context based on parameters and error type
@@ -171,19 +219,16 @@ module RailsAiContext
171
219
  lines.concat(log_section) if log_section.any?
172
220
 
173
221
  # Next steps
174
- next_steps = []
175
- if file
176
- next_steps << "_Use `rails_get_edit_context(file:\"#{file}\", near:\"#{parsed[:method_name] || line || parsed[:exception_class]}\")` to see the code._"
177
- end
178
- if parsed[:method_name]
179
- next_steps << "_Use `rails_search_code(pattern:\"#{parsed[:method_name]}\", match_type:\"trace\")` to trace the method._"
180
- end
222
+ next_steps = [
223
+ (pointer("rails_get_edit_context", %(file:"#{file}", near:"#{parsed[:method_name] || line || parsed[:exception_class]}"), "to see the code.") if file),
224
+ (pointer("rails_search_code", %(pattern:"#{parsed[:method_name]}", match_type:"trace"), "to trace the method.") if parsed[:method_name])
225
+ ].compact.map { |step| "_Use #{step}_" }
181
226
  if next_steps.any?
182
227
  lines << "## Next Steps"
183
228
  lines.concat(next_steps)
184
229
  end
185
230
 
186
- output = lines.join("\n")
231
+ output = lines.join("\n").rstrip
187
232
 
188
233
  # Final safety cap: if total output still exceeds limit, hard-truncate
189
234
  if output.length > MAX_TOTAL_OUTPUT
@@ -192,8 +237,6 @@ module RailsAiContext
192
237
 
193
238
  # The diagnosis still answers the error, and the result is an error as every refusal is.
194
239
  refusal ? error_response(output) : text_response(output)
195
- rescue => e
196
- text_response("Diagnosis error: #{e.message}")
197
240
  end
198
241
 
199
242
  # A name an error message gives its exception: a class ending the way
@@ -203,6 +246,18 @@ module RailsAiContext
203
246
  class << self
204
247
  private
205
248
 
249
+ # Numbered steps; one whose text holds a tool call (%s) is left out
250
+ # when the app does not serve that tool.
251
+ def fix_steps(steps)
252
+ kept = steps.filter_map do |step|
253
+ next step unless step.is_a?(Hash)
254
+
255
+ call = pointer(step[:tool], step[:args])
256
+ step[:text].sub("%s") { call } if call
257
+ end
258
+ kept.each_with_index.map { |step, index| "#{index + 1}. #{step}" }.join("\n")
259
+ end
260
+
206
261
  def parse_error(error_string)
207
262
  result = { exception_class: nil, message: error_string.strip, file_refs: [], method_name: nil }
208
263
 
@@ -297,9 +352,11 @@ module RailsAiContext
297
352
  {
298
353
  type: :unknown,
299
354
  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"
355
+ fix: [
356
+ "Check the error message for clues about what went wrong",
357
+ { tool: "rails_search_code", args: %(pattern:"#{exception_class&.split("::")&.last || "ErrorName"}"), text: "Use %s to find where the app raises or rescues it" },
358
+ { tool: "rails_read_logs", args: %(level:"ERROR"), text: "Use %s for the backtrace" }
359
+ ]
303
360
  }
304
361
  end
305
362
 
@@ -347,8 +404,10 @@ module RailsAiContext
347
404
  return {
348
405
  type: :non_public_method,
349
406
  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"
407
+ fix: [
408
+ "Call it from inside the class, or make it public if it is part of the interface",
409
+ { tool: "rails_search_code", args: %(pattern:"#{m[2]}", match_type:"definition"), text: "Use %s to see where it is defined and under which visibility" }
410
+ ]
352
411
  }
353
412
  end
354
413
 
@@ -367,9 +426,11 @@ module RailsAiContext
367
426
  {
368
427
  type: :undefined_method,
369
428
  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"
429
+ fix: [
430
+ suggestion ? "Call `#{suggestion}`, the method #{subject} has" : "Check the name against the methods #{subject} defines",
431
+ "Check the call goes to the object you think: an association or a presenter can hand back another class",
432
+ { tool: "rails_search_code", args: %(pattern:"#{name}", match_type:"trace"), text: "Use %s to find its definition and its callers" }
433
+ ]
373
434
  }
374
435
  end
375
436
 
@@ -448,9 +509,11 @@ module RailsAiContext
448
509
  {
449
510
  type: :undefined_method_on_model,
450
511
  likely: likely,
451
- fix: "1. Run `rails_get_model_details(model:\"#{receiver}\")` to see #{receiver}'s real associations and columns\n" \
452
- "2. Check for a typo in the method name or a missing association declaration (has_many/belongs_to)\n" \
453
- "3. If the data lives on another model, add the association or a delegate"
512
+ fix: [
513
+ { tool: "rails_get_model_details", args: %(model:"#{receiver}"), text: "Run %s to see #{receiver}'s real associations and columns" },
514
+ "Check for a typo in the method name or a missing association declaration (has_many/belongs_to)",
515
+ "If the data lives on another model, add the association or a delegate"
516
+ ]
454
517
  }
455
518
  end
456
519
 
@@ -506,29 +569,29 @@ module RailsAiContext
506
569
  if ctrl && act
507
570
  add_section(lines, "Controller Context", report_error: true) do
508
571
  ctrl_class = ctrl.end_with?("Controller") ? ctrl : "#{ctrl.camelize}Controller"
509
- GetControllers.call(controller: ctrl_class, action: act)
572
+ compose(GetControllers, controller: ctrl_class, action: act)
510
573
  end
511
574
  end
512
575
 
513
576
  if file && line
514
577
  add_section(lines, "Code Context", report_error: true) do
515
- GetEditContext.call(file: file, near: parsed[:method_name] || line.to_s)
578
+ compose(GetEditContext, file: file, near: parsed[:method_name] || line.to_s)
516
579
  end
517
580
  end
518
581
 
519
582
  if classification[:type] == :schema_mismatch
520
583
  table = parsed[:message].match(/(?:table|relation)\s+["']?(\w+)["']?/i)&.[](1)
521
- add_section(lines, "Schema Context") { GetSchema.call(table: table) } if table
584
+ add_section(lines, "Schema Context") { compose(GetSchema, table: table) } if table
522
585
  end
523
586
 
524
587
  if classification[:type] == :validation_failure
525
588
  model_name = file&.match(%r{app/models/(.+)\.rb})&.[](1)&.camelize
526
- add_section(lines, "Model Context") { GetModelDetails.call(model: model_name) } if model_name
589
+ add_section(lines, "Model Context") { compose(GetModelDetails, model: model_name) } if model_name
527
590
  end
528
591
 
529
592
  if parsed[:method_name] && lines.none? { |l| l.include?("Code Context") }
530
593
  add_section(lines, "Method Trace") do
531
- result = SearchCode.call(pattern: parsed[:method_name], match_type: "trace")
594
+ result = compose(SearchCode, pattern: parsed[:method_name], match_type: "trace")
532
595
  # A trace that found callers but no `def` is still not the
533
596
  # method's definition, which is what this section promises.
534
597
  definition_missing?(result) ? nil : result
@@ -566,7 +629,7 @@ module RailsAiContext
566
629
  # Check if calling on an association
567
630
  if method && file
568
631
  begin
569
- ctx = GetEditContext.call(file: file, near: method)
632
+ ctx = compose(GetEditContext, file: file, near: method)
570
633
  code = response_text(ctx)
571
634
  # Find the receiver: something.method_name
572
635
  receiver_match = code.match(/(\w+)\.#{Regexp.escape(method)}/)
@@ -586,7 +649,7 @@ module RailsAiContext
586
649
  if ctrl && act
587
650
  begin
588
651
  ctrl_class = ctrl.end_with?("Controller") ? ctrl : "#{ctrl.camelize}Controller"
589
- result = GetControllers.call(controller: ctrl_class, action: act)
652
+ result = compose(GetControllers, controller: ctrl_class, action: act)
590
653
  text = response_text(result)
591
654
  if text.include?("set_") && text.include?("find")
592
655
  return "The `set_*` before_action uses `.find` which raises RecordNotFound. " \
@@ -31,7 +31,7 @@ module RailsAiContext
31
31
  )
32
32
 
33
33
  guide_row(
34
- order: 34,
34
+ order: 29,
35
35
  mcp: "rails_generate_test(model:\"X\")",
36
36
  cli_args: "model=X",
37
37
  summary: "Generate test scaffolding matching project patterns (framework, factories, style)"
@@ -61,8 +61,6 @@ module RailsAiContext
61
61
  elsif file
62
62
  generate_file_test(file.strip, framework, tests_data, type)
63
63
  end
64
- rescue => e
65
- text_response("Generate test error: #{e.message}")
66
64
  end
67
65
 
68
66
  class << self
@@ -106,7 +104,7 @@ module RailsAiContext
106
104
  key = fuzzy_find_key(models.keys, model_name)
107
105
  unless key
108
106
  return not_found_response("Model", model_name, models.keys.sort,
109
- recovery_tool: "Call rails_get_model_details(detail:\"summary\") to see all models")
107
+ recovery_tool: call_pointer("rails_get_model_details", %(detail:"summary"), "to see all models"))
110
108
  end
111
109
 
112
110
  data = models[key]
@@ -512,7 +510,7 @@ module RailsAiContext
512
510
  ctrl_class = RailsAiContext::Payload.find_controller(cached_context, ctrl_name)
513
511
  if known.any? && ctrl_class.nil?
514
512
  return not_found_response("Controller", ctrl_name, known.keys.sort,
515
- recovery_tool: "Call rails_get_controllers(detail:\"summary\") to see all controllers")
513
+ recovery_tool: call_pointer("rails_get_controllers", %(detail:"summary"), "to see all controllers"))
516
514
  end
517
515
 
518
516
  ctrl_class ||= ctrl_name.end_with?("Controller") ? ctrl_name : "#{ctrl_name.camelize}Controller"
@@ -1673,7 +1671,7 @@ module RailsAiContext
1673
1671
  key = fuzzy_find_key(models.keys, model_name)
1674
1672
  unless key
1675
1673
  return not_found_response("Model", model_name, models.keys.sort,
1676
- recovery_tool: "Call rails_get_model_details(detail:\"summary\") to see all models")
1674
+ recovery_tool: call_pointer("rails_get_model_details", %(detail:"summary"), "to see all models"))
1677
1675
  end
1678
1676
  RailsAiContext::Payload.controller_for_route_key(cached_context, key.to_s.underscore.pluralize)&.first
1679
1677
  end
@@ -4,17 +4,12 @@ module RailsAiContext
4
4
  module Tools
5
5
  class GetActiveSupport < BaseTool
6
6
  tool_name "rails_get_active_support"
7
+ section_renderer!
7
8
  description "Get ActiveSupport surface: concern modules across app/**/concerns, registered deprecators, MessageVerifier/MessageEncryptor usage, the ActiveSupport::Notifications events the app subscribes to and where, tagged logging, subscribed on_load hooks, and cache store. " \
8
9
  "Use when: checking framework-level wiring, finding where crypto helpers are used, or understanding boot-time hooks."
9
10
 
10
11
  input_schema(properties: {})
11
12
 
12
- guide_row(
13
- order: 44,
14
- mcp: "rails_get_active_support",
15
- summary: "Concerns registry, deprecators, MessageVerifier usage, notification subscriptions, on_load hooks, cache store"
16
- )
17
-
18
13
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
19
14
 
20
15
  def self.call(server_context: nil)