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
@@ -4,6 +4,23 @@ module RailsAiContext
4
4
  module Tools
5
5
  class GetContext < BaseTool
6
6
  tool_name "rails_get_context"
7
+
8
+ # Each include key and the call it bundles, run only when the app serves that tool.
9
+ INCLUDE_MAP = {
10
+ "stimulus" => [ "rails_get_stimulus", { detail: "standard" } ],
11
+ "turbo" => [ "rails_get_turbo_map", { detail: "standard" } ],
12
+ "services" => [ "rails_get_service_pattern", { detail: "standard" } ],
13
+ "jobs" => [ "rails_get_job_pattern", { detail: "standard" } ],
14
+ "conventions" => [ "rails_get_config", { section: "conventions" } ],
15
+ "helpers" => [ "rails_get_helper_methods", { detail: "standard" } ],
16
+ "env" => [ "rails_get_config", { section: "env", detail: "summary" } ],
17
+ "callbacks" => [ "rails_get_callbacks", { detail: "standard" } ],
18
+ "tests" => [ "rails_get_test_info", { detail: "full" } ],
19
+ "config" => [ "rails_get_config", { section: "app" } ],
20
+ "gems" => [ "rails_get_config", { section: "gems" } ],
21
+ "security" => [ "rails_security_scan", { detail: "summary" } ]
22
+ }.freeze
23
+
7
24
  description "Get cross-layer context in a single call - combines schema, model, controller, routes, views, stimulus, and tests. " \
8
25
  "Use when: you need full context for implementing a feature or modifying an action. " \
9
26
  "Specify controller:\"PostsController\" action:\"create\" to get everything for that action in one call."
@@ -29,7 +46,7 @@ module RailsAiContext
29
46
  include: {
30
47
  type: "array",
31
48
  items: { type: "string" },
32
- description: "Additional context to bundle: 'stimulus', 'turbo', 'services', 'jobs', 'conventions', 'helpers', 'env', 'callbacks'. Appends these to any mode."
49
+ description: "Additional context to bundle: #{INCLUDE_MAP.keys.map { |key| "'#{key}'" }.join(', ')}. Appends these to any mode."
33
50
  }
34
51
  }
35
52
  )
@@ -56,8 +73,7 @@ module RailsAiContext
56
73
  return error_response("Provide at least one of: controller, model, or feature.")
57
74
  end
58
75
 
59
- # Append additional context sections if include: is specified
60
- base_text += append_includes(include) if include.is_a?(Array) && include.any?
76
+ base_text = sections(base_text, *include_sections(include)) if include.is_a?(Array) && include.any?
61
77
 
62
78
  ctx = begin
63
79
  cached_context
@@ -68,31 +84,26 @@ module RailsAiContext
68
84
  text_response(base_text, suffix: introspection_warnings_note(ctx))
69
85
  end
70
86
 
71
- private_class_method def self.controller_action_context(controller_name, action_name)
72
- lines = []
87
+ # The sections that have text, with a rule between them.
88
+ private_class_method def self.sections(*texts)
89
+ texts.compact.reject(&:empty?).join("\n\n---\n\n")
90
+ end
73
91
 
92
+ private_class_method def self.section_text(response)
93
+ response_text(response) unless empty?(response)
94
+ end
95
+
96
+ private_class_method def self.controller_action_context(controller_name, action_name)
74
97
  # Controller + action source + private methods + instance vars
75
- ctrl_result = GetControllers.call(controller: controller_name, action: action_name)
76
- lines << response_text(ctrl_result)
98
+ ctrl_text = controller_text(compose(GetControllers, controller: controller_name, action: action_name), controller_name, action_name)
77
99
 
78
100
  # Infer model from controller
79
101
  snake = RailsAiContext::Payload.controller_route_key(cached_context, controller_name)
80
102
  model_name = snake.split("/").last.singularize.camelize
81
103
 
82
- # Model details
83
- model_result = GetModelDetails.call(model: model_name)
84
- unless empty?(model_result)
85
- lines << "" << "---" << "" << response_text(model_result)
86
- end
87
-
88
- # Routes for this controller
89
- route_result = GetRoutes.call(controller: snake)
90
- unless empty?(route_result)
91
- lines << "" << "---" << ""
92
- lines << response_text(route_result)
93
- end
94
-
95
- lines.concat(view_section_lines(snake))
104
+ text = sections(ctrl_text, section_text(compose(GetModelDetails, model: model_name)),
105
+ section_text(compose(GetRoutes, controller: snake)), view_section(snake))
106
+ extras = []
96
107
 
97
108
  # Cross-reference: controller ivars vs view ivars. The action's own
98
109
  # source, the methods it calls and the before filters that run for it
@@ -117,7 +128,7 @@ module RailsAiContext
117
128
  if Payload.section(cached_context, :view_templates)
118
129
  ivar_check = cross_reference_ivars(ctrl_ivars, view_ivars, rendered_templates: other_templates, api_only: api_only_app?,
119
130
  origins: origins.except(*(action_body ? resolver.assigned_ivars(action_body) : [])))
120
- lines << "" << ivar_check if ivar_check
131
+ extras << ivar_check if ivar_check
121
132
  end
122
133
 
123
134
  # Hydrate: inject schema hints for models referenced in controller + view ivars
@@ -126,14 +137,37 @@ module RailsAiContext
126
137
  hydration = Hydrators::ViewHydrator.call(all_ivars, context: cached_context)
127
138
  hydration_text = Hydrators::HydrationFormatter.format(hydration)
128
139
  # Skip if get_controllers already injected schema hints
129
- unless hydration_text.empty? || lines.any? { |l| l.include?("## Schema Hints") }
130
- lines << "" << hydration_text
131
- end
140
+ extras << hydration_text unless hydration_text.empty? || text.include?("## Schema Hints")
132
141
  end
133
142
 
134
- lines.join("\n")
135
- rescue => e
136
- "Error assembling context: #{e.message}"
143
+ [ text, *extras ].reject(&:empty?).join("\n\n")
144
+ end
145
+
146
+ # The controller section. With rails_get_controllers off it is empty, so a controller
147
+ # or an action the app does not have is answered here as that tool answers it.
148
+ private_class_method def self.controller_text(result, name, action = nil)
149
+ ctx = cached_context
150
+ return response_text(result) if own_served?(GetControllers)
151
+
152
+ key = Payload.find_controller(ctx, name)
153
+ known = key || name.to_s.strip.casecmp?("ApplicationController")
154
+ return response_text(not_found_response("Controller", name, Payload.app_controllers(ctx).keys.sort)) unless known
155
+
156
+ (action && key && missing_action_text(ctx, key, action)).to_s
157
+ end
158
+
159
+ private_class_method def self.missing_action_text(ctx, name, action)
160
+ controllers = Payload.controllers(ctx)
161
+ info = controllers[name]
162
+ return nil unless info.is_a?(Hash)
163
+
164
+ name, info = info[:alias_of], controllers[info[:alias_of]] if controllers[info[:alias_of]].is_a?(Hash)
165
+ actions = Array(info[:actions]).map { |a| (a.is_a?(Hash) ? a[:name] : a).to_s }
166
+ return nil if actions.any? { |a| a.casecmp?(action.to_s) }
167
+ return nil if RailsAiContext::Introspectors::ActionSource.unlisted_action(rails_app.root, name, action.to_s,
168
+ file: Payload.controller_file(ctx, name), controllers: controllers)
169
+
170
+ GetControllers.missing_action_message(name, action, actions)
137
171
  end
138
172
 
139
173
  # The templates for a controller, read from the directory Rails would
@@ -143,23 +177,23 @@ module RailsAiContext
143
177
  # convention - on a namespaced app they belong to another component
144
178
  # entirely.
145
179
  #
146
- private_class_method def self.view_section_lines(snake)
180
+ private_class_method def self.view_section(snake)
147
181
  view_result, view_note = controller_views(snake)
148
- return [] if empty?(view_result)
182
+ return nil if empty?(view_result)
149
183
 
150
- [ "", "---", "", view_note, response_text(view_result) ].compact
184
+ [ view_note, response_text(view_result) ].compact.join("\n")
151
185
  end
152
186
 
153
187
  # @return [Array(MCP::Tool::Response, String, nil)] the view section and
154
188
  # a note naming the directory it came from
155
189
  private_class_method def self.controller_views(snake)
156
- namespaced = GetView.call(controller: snake, detail: "standard")
190
+ namespaced = compose(GetView, controller: snake, detail: "standard")
157
191
  return [ namespaced, "_Views from `app/views/#{snake}`._" ] unless empty?(namespaced)
158
192
 
159
193
  basename = snake.to_s.split("/").last
160
194
  return [ namespaced, nil ] if basename.nil? || basename == snake
161
195
 
162
- flat = GetView.call(controller: basename, detail: "standard")
196
+ flat = compose(GetView, controller: basename, detail: "standard")
163
197
  return [ namespaced, nil ] if empty?(flat)
164
198
 
165
199
  [ flat, "_No templates under `app/views/#{snake}`; these are `app/views/#{basename}`, which Rails resolves for this controller only if it sets its own view path._" ]
@@ -264,29 +298,13 @@ module RailsAiContext
264
298
  end
265
299
 
266
300
  private_class_method def self.controller_context(controller_name)
267
- lines = []
268
-
269
- ctrl_result = GetControllers.call(controller: controller_name)
270
- lines << response_text(ctrl_result)
271
-
301
+ ctrl_text = controller_text(compose(GetControllers, controller: controller_name), controller_name)
272
302
  snake = RailsAiContext::Payload.controller_route_key(cached_context, controller_name)
273
303
 
274
- # Routes for this controller
275
- route_result = GetRoutes.call(controller: snake)
276
- unless empty?(route_result)
277
- lines << "" << "---" << "" << response_text(route_result)
278
- end
279
-
280
- lines.concat(view_section_lines(snake))
281
-
282
- lines.join("\n")
283
- rescue => e
284
- "Error assembling context: #{e.message}"
304
+ sections(ctrl_text, section_text(compose(GetRoutes, controller: snake)), view_section(snake))
285
305
  end
286
306
 
287
307
  private_class_method def self.model_context(model_name)
288
- lines = []
289
-
290
308
  # Normalize: try as-is, then singularized, then classified
291
309
  ctx = cached_context
292
310
  models = Payload.models(ctx)
@@ -294,64 +312,49 @@ module RailsAiContext
294
312
 
295
313
  resolved_name = key || model_name
296
314
 
297
- model_result = GetModelDetails.call(model: resolved_name)
298
-
299
- # If model not found, fail fast - don't leak partial results from sub-tools
300
- return response_text(model_result) if empty?(model_result)
301
-
302
- lines << response_text(model_result)
303
-
304
- if key && models[key][:table_name]
305
- schema_result = GetSchema.call(table: models[key][:table_name])
306
- unless empty?(schema_result)
307
- lines << "" << "---" << "" << response_text(schema_result)
308
- end
309
- end
315
+ model_result = compose(GetModelDetails, model: resolved_name)
310
316
 
311
- # Tests for this model
312
- test_result = GetTestInfo.call(model: resolved_name, detail: "standard")
313
- unless empty?(test_result)
314
- lines << "" << "---" << "" << response_text(test_result)
315
- end
317
+ # If model not found, fail fast - don't leak partial results from sub-tools.
318
+ # A model the app has still gets its schema and tests when model details is turned off.
319
+ served = own_served?(GetModelDetails)
320
+ return response_text(not_found_response("Model", model_name.to_s.strip, models.keys.sort)) if key.nil? && !served
321
+ return response_text(model_result) if empty?(model_result) && served
316
322
 
317
- lines.join("\n")
318
- rescue => e
319
- "Error assembling context: #{e.message}"
323
+ table = key && models[key][:table_name]
324
+ sections(response_text(model_result), (section_text(compose(GetSchema, table: table)) if table),
325
+ section_text(compose(GetTestInfo, model: resolved_name, detail: "standard")))
320
326
  end
321
327
 
322
- INCLUDE_MAP = {
323
- "stimulus" => -> { GetStimulus.call(detail: "standard") },
324
- "turbo" => -> { GetTurboMap.call(detail: "standard") },
325
- "services" => -> { GetServicePattern.call(detail: "standard") },
326
- "jobs" => -> { GetJobPattern.call(detail: "standard") },
327
- "conventions" => -> { GetConventions.call },
328
- "helpers" => -> { GetHelperMethods.call(detail: "standard") },
329
- "env" => -> { GetEnv.call(detail: "summary") },
330
- "callbacks" => -> { GetCallbacks.call(detail: "standard") },
331
- "tests" => -> { GetTestInfo.call(detail: "full") },
332
- "config" => -> { GetConfig.call },
333
- "gems" => -> { GetGems.call },
334
- "security" => -> { SecurityScan.call(detail: "summary") }
335
- }.freeze
328
+ private_class_method def self.include_sections(includes)
329
+ includes.uniq { |key| key.to_s.downcase }.map do |key|
330
+ name, args = INCLUDE_MAP[key.to_s.downcase]
331
+ next "_`include` does not take '#{echo_input(key)}'. It takes: #{INCLUDE_MAP.keys.join(', ')}._" unless name
332
+
333
+ off = include_off_message(name, args)
334
+ next "_#{off}_" if off
336
335
 
337
- private_class_method def self.append_includes(includes)
338
- extra = +""
339
- includes.each do |key|
340
- handler = INCLUDE_MAP[key.to_s.downcase]
341
- next unless handler
342
336
  begin
343
- extra << "\n\n---\n\n" << response_text(handler.call)
337
+ response_text(tool_class(name).call(**args))
344
338
  rescue => e
345
- extra << "\n\n---\n\n_Error loading #{key}: #{e.message}_"
339
+ "_Error loading #{key}: #{e.message}_"
346
340
  end
347
341
  end
348
- extra
342
+ end
343
+
344
+ # Why the app does not serve an include's tool or config section, or nil when it does.
345
+ private_class_method def self.include_off_message(name, args)
346
+ section = GetConfig.section_named(args[:section]) if name == "rails_get_config"
347
+ return section.off_message if section && !section.on?
348
+
349
+ RailsAiContext::Server.skipped_tool_message(name) unless section ? section.served? : own_served?(tool_class(name))
349
350
  end
350
351
 
351
352
  private_class_method def self.feature_context(feature_name)
352
353
  # Start with full-stack feature analysis
353
- analyze_result = AnalyzeFeature.call(feature: feature_name)
354
- lines = [ response_text(analyze_result) ]
354
+ analyze_result = compose(AnalyzeFeature, feature: feature_name)
355
+ parts = [ response_text(analyze_result) ]
356
+ related = []
357
+ matched_models = []
355
358
 
356
359
  # Enrich with schema columns for matching models
357
360
  ctx = begin; cached_context; rescue; nil; end
@@ -361,13 +364,11 @@ module RailsAiContext
361
364
 
362
365
  models.each_key do |model_name|
363
366
  next unless model_name.downcase.include?(feature_name.downcase)
367
+ matched_models << model_name
364
368
  table_name = models[model_name][:table_name]
365
369
  next unless table_name
366
370
  matched_tables << table_name
367
- schema_result = GetSchema.call(table: table_name)
368
- unless empty?(schema_result)
369
- lines << "" << "---" << "" << response_text(schema_result)
370
- end
371
+ parts << section_text(compose(GetSchema, table: table_name))
371
372
  end
372
373
 
373
374
  # Also include schema for related models (associated tables) if the
@@ -382,21 +383,26 @@ module RailsAiContext
382
383
  c_name.downcase.include?(feature_name.pluralize.downcase)
383
384
  end
384
385
  if related_ctrls.any?
385
- lines << "" << "## Related Controllers (by name)"
386
+ related << "## Related Controllers (by name)"
386
387
  related_ctrls.each do |c_name, c|
387
388
  actions = (c[:actions] || []).map { |a| a.is_a?(Hash) ? a[:name] : a }.compact
388
- lines << "- **#{c_name}** - #{actions.join(', ')}"
389
+ related << "- **#{c_name}** - #{actions.join(', ')}"
389
390
  end
390
391
  end
391
-
392
392
  end
393
393
  end
394
394
 
395
- lines.join("\n")
395
+ text = [ sections(*parts), related.join("\n") ].reject(&:empty?).join("\n\n")
396
+ return text unless text.empty? && !own_served?(AnalyzeFeature)
397
+
398
+ # With rails_analyze_feature off, only model and controller names were matched.
399
+ return "The model names that hold '#{echo_input(feature_name)}': #{matched_models.sort.join(', ')}." if matched_models.any?
400
+
401
+ "No matches found for '#{echo_input(feature_name)}'. No model or controller name holds this keyword.#{AnalyzeFeature.model_names_hint(ctx)}"
396
402
  rescue => e
397
403
  # Fall back to plain analyze_feature on error
398
404
  RailsAiContext.debug_fail(e, label: "feature_context")
399
- response_text(AnalyzeFeature.call(feature: feature_name))
405
+ response_text(compose(AnalyzeFeature, feature: feature_name))
400
406
  end
401
407
  end
402
408
  end
@@ -287,6 +287,10 @@ module RailsAiContext
287
287
  end
288
288
  end
289
289
 
290
+ def self.missing_action_message(controller_name, action_name, actions)
291
+ "Action '#{echo_input(action_name)}' not found in #{controller_name}. Available: #{actions.join(', ')}"
292
+ end
293
+
290
294
  private_class_method def self.format_action_source(controller_name, info, action_name)
291
295
  # An alias is the same class: its actions are read from the controller it names.
292
296
  target = info[:alias_of] && RailsAiContext::Payload.controllers(cached_context)[info[:alias_of]]
@@ -304,7 +308,7 @@ module RailsAiContext
304
308
  unlisted = RailsAiContext::Introspectors::ActionSource.unlisted_action(rails_app.root, controller_name, action_name,
305
309
  file: carried, controllers: RailsAiContext::Payload.controllers(cached_context))
306
310
  unless unlisted
307
- return empty_response("Action '#{echo_input(action_name)}' not found in #{controller_name}. Available: #{actions.join(', ')}")
311
+ return empty_response(missing_action_message(controller_name, action_name, actions))
308
312
  end
309
313
 
310
314
  action_name = unlisted[:name]
@@ -681,12 +685,11 @@ module RailsAiContext
681
685
  model_name = RailsAiContext::Introspectors::TableName.model_for(
682
686
  ctrl_path.split("/").last.singularize.camelize, nil, RailsAiContext::Payload.models(ctx)
683
687
  )
684
- lines << ""
685
- lines << "_Next: `rails_get_routes(controller:\"#{ctrl_path}\")` for routes"
686
- lines << " | `rails_get_model_details(model:\"#{model_name}\")` for model" if model_name
687
688
  # The full path, which is the directory Rails resolves templates in:
688
689
  # the basename reads another component's views on a namespaced app.
689
- lines << " | `rails_get_view(controller:\"#{ctrl_path}\")` for views_"
690
+ add_next_hint(lines, pointer("rails_get_routes", %(controller:"#{ctrl_path}"), "for routes"),
691
+ (pointer("rails_get_model_details", %(model:"#{model_name}"), "for model") if model_name),
692
+ pointer("rails_get_view", %(controller:"#{ctrl_path}"), "for views"), lines: true)
690
693
 
691
694
  lines.join("\n")
692
695
  end
@@ -4,18 +4,13 @@ module RailsAiContext
4
4
  module Tools
5
5
  class GetConventions < BaseTool
6
6
  tool_name "rails_get_conventions"
7
+ section_renderer!
7
8
  description "Detect app architecture and conventions: API-only vs Hotwire, design patterns, directory layout. " \
8
9
  "Use when: starting work on an unfamiliar codebase, choosing implementation patterns, or checking what frameworks are in use. " \
9
10
  "No parameters needed. Returns architecture style, detected patterns (STI, service objects), and notable config files."
10
11
 
11
12
  input_schema(properties: {})
12
13
 
13
- guide_row(
14
- order: 23,
15
- mcp: "rails_get_conventions",
16
- summary: "App patterns: auth checks, flash messages, test patterns"
17
- )
18
-
19
14
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
20
15
 
21
16
  def self.call(server_context: nil)
@@ -4,17 +4,12 @@ module RailsAiContext
4
4
  module Tools
5
5
  class GetEngines < BaseTool
6
6
  tool_name "rails_get_engines"
7
+ section_renderer!
7
8
  description "Get what config/routes.rb mounts - engines and plain Rack apps alike, with known-engine descriptions - the app's own in-repo engines, plugins and modules, and every loaded Rails::Engine subclass (routes and model counts). " \
8
9
  "Use when: checking what is mounted where, finding an admin dashboard's path, listing the repository's own engines, or understanding engine-provided routes."
9
10
 
10
11
  input_schema(properties: {})
11
12
 
12
- guide_row(
13
- order: 42,
14
- mcp: "rails_get_engines",
15
- summary: "Mounted apps (engines and Rack apps) + in-repo engines + loaded engine classes with route/model counts"
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)
@@ -7,6 +7,7 @@ module RailsAiContext
7
7
  module Tools
8
8
  class GetEnv < BaseTool
9
9
  tool_name "rails_get_env"
10
+ section_renderer!
10
11
  description "Discover environment variables, external service dependencies, and credentials keys used by the app. " \
11
12
  "Use when: setting up a development environment, debugging missing config, or auditing external dependencies. " \
12
13
  "Scans .rb, .rake, ERB views and config YAML for ENV[], plus .env.example, Dockerfile, the env Kamal's config/deploy.yml sets, config gem settings and Anyway::Config keys, external HTTP calls, and credentials keys (never values)."
@@ -17,12 +18,6 @@ module RailsAiContext
17
18
  }
18
19
  )
19
20
 
20
- guide_row(
21
- order: 17,
22
- mcp: "rails_get_env",
23
- summary: "Environment variables + credentials keys (not values)"
24
- )
25
-
26
21
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
27
22
 
28
23
  # One wording for the same fact on both detail levels.
@@ -4,11 +4,12 @@ module RailsAiContext
4
4
  module Tools
5
5
  class GetEnvConfig < BaseTool
6
6
  tool_name "rails_get_env_config"
7
+ section_renderer!
7
8
  description "Get per-environment configuration from config/environments/*.rb: notable toggles (force_ssl, eager_load, caching, log level, queue adapter, mailer delivery) and every config key each environment sets, " \
8
9
  "plus the keys config/application.rb sets for every environment (config.x included) and the keys each config_for YAML file gives. " \
9
10
  "Use when: comparing development vs production behavior, debugging env-specific bugs, or checking what a custom environment changes. " \
10
11
  "Filter with environment:\"production\". Omit for all environments. " \
11
- "For environment variables and ENV[] usage, use rails_get_env instead."
12
+ "For environment variables and ENV[] usage, use rails_get_config(section:\"env\") instead."
12
13
 
13
14
  input_schema(
14
15
  properties: {
@@ -27,13 +28,6 @@ module RailsAiContext
27
28
  }
28
29
  )
29
30
 
30
- guide_row(
31
- order: 45,
32
- mcp: "rails_get_env_config(environment:\"production\")",
33
- cli_args: "environment=production",
34
- summary: "Per-environment config: notable toggles + config keys each env sets"
35
- )
36
-
37
31
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
38
32
 
39
33
  def self.call(environment: nil, offset: 0, limit: nil, server_context: nil)
@@ -4,6 +4,7 @@ module RailsAiContext
4
4
  module Tools
5
5
  class GetFrontendStack < BaseTool
6
6
  tool_name "rails_get_frontend_stack"
7
+ section_renderer!
7
8
  description "Returns the app's frontend stack: framework, build tool, state management, TypeScript config, " \
8
9
  "component directories, and package manager. " \
9
10
  "Use when: scaffolding new frontend features, choosing libraries, or understanding the JS/TS build pipeline. " \
@@ -15,12 +16,6 @@ module RailsAiContext
15
16
  }
16
17
  )
17
18
 
18
- guide_row(
19
- order: 29,
20
- mcp: "rails_get_frontend_stack",
21
- summary: "React/Vue/Svelte/Angular, Inertia, TypeScript, package manager"
22
- )
23
-
24
19
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
25
20
 
26
21
  def self.call(detail: "standard", server_context: nil)
@@ -4,6 +4,7 @@ module RailsAiContext
4
4
  module Tools
5
5
  class GetGems < BaseTool
6
6
  tool_name "rails_get_gems"
7
+ section_renderer!
7
8
  description "Get notable gems from Gemfile.lock grouped by category (auth, jobs, frontend, API, database, testing, deploy and more). " \
8
9
  "Use when: checking what libraries are available before adding a dependency, or understanding the tech stack. " \
9
10
  "Filter with category:\"auth\" or category:\"database\". Omit for all categories."
@@ -26,12 +27,6 @@ module RailsAiContext
26
27
  }
27
28
  )
28
29
 
29
- guide_row(
30
- order: 22,
31
- mcp: "rails_get_gems",
32
- summary: "Notable gems with versions, categories, config file locations"
33
- )
34
-
35
30
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
36
31
 
37
32
  # A `config/initializers/` hint is resolved through PathResolver, so the
@@ -55,7 +55,7 @@ module RailsAiContext
55
55
  )
56
56
 
57
57
  guide_row(
58
- order: 20,
58
+ order: 19,
59
59
  mcp: "rails_get_helper_methods",
60
60
  summary: "App + framework helpers with view cross-references"
61
61
  )
@@ -208,11 +208,10 @@ module RailsAiContext
208
208
  # named for, and only those the app has: ApplicationHelper has no
209
209
  # controller of its own to send the reader to.
210
210
  route_key = relative_under(file_path, helper_dirs).delete_suffix(".rb").delete_suffix("_helper")
211
- hints = []
212
- hints << "`rails_get_view(controller:\"#{route_key}\")` for views" if RailsAiContext::PathResolver.view_dirs(root).any? { |dir| Dir.exist?(File.join(dir, route_key)) }
211
+ views = RailsAiContext::PathResolver.view_dirs(root).any? { |dir| Dir.exist?(File.join(dir, route_key)) }
213
212
  controller = Payload.controller_for_route_key(cached_context, route_key)&.first
214
- hints << "`rails_get_controllers(controller:\"#{controller}\")` for controller" if controller
215
- lines << "" << "_Next: #{hints.join(' | ')}_" if hints.any?
213
+ add_next_hint(lines, (pointer("rails_get_view", %(controller:"#{route_key}"), "for views") if views),
214
+ (pointer("rails_get_controllers", %(controller:"#{controller}"), "for controller") if controller))
216
215
 
217
216
  text_response(lines.join("\n"))
218
217
  end
@@ -4,6 +4,7 @@ module RailsAiContext
4
4
  module Tools
5
5
  class GetI18n < BaseTool
6
6
  tool_name "rails_get_i18n"
7
+ section_renderer!
7
8
  description "Get I18n setup: default/available locales, backend, locale files with key counts, per-locale coverage vs the default locale, and fallbacks. " \
8
9
  "Use when: adding translations, checking locale coverage before shipping, or finding which file defines a locale. " \
9
10
  "Filter with locale:\"fr\". Omit for the full picture."
@@ -25,13 +26,6 @@ module RailsAiContext
25
26
  }
26
27
  )
27
28
 
28
- guide_row(
29
- order: 40,
30
- mcp: "rails_get_i18n(locale:\"fr\")",
31
- cli_args: "locale=fr",
32
- summary: "Locales, locale files with key counts, per-locale coverage, fallbacks"
33
- )
34
-
35
29
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
36
30
 
37
31
  def self.call(locale: nil, offset: 0, limit: nil, server_context: nil)
@@ -383,7 +383,7 @@ module RailsAiContext
383
383
  end
384
384
 
385
385
  # Cross-reference hints
386
- lines << "" << "_Next: `rails_search_code(pattern:\"#{class_name}\")` for all references_"
386
+ add_next_hint(lines, pointer("rails_search_code", %(pattern:"#{class_name}"), "for all references"))
387
387
 
388
388
  text_response(lines.join("\n"))
389
389
  end
@@ -28,7 +28,7 @@ module RailsAiContext
28
28
  )
29
29
 
30
30
  guide_row(
31
- order: 41,
31
+ order: 35,
32
32
  mcp: "rails_get_mailers(mailer:\"X\")",
33
33
  cli_args: "mailer=X",
34
34
  summary: "Mailer classes with delivery actions and delivery method, mailboxes and their routing"
@@ -217,8 +217,8 @@ module RailsAiContext
217
217
  end
218
218
  differ = others.reject { |_, other| other[:columns] == table_data[:columns] }.map(&:first)
219
219
  if differ.any?
220
- lines << "" << "Columns of `#{data[:table_name]}` in #{source}; the table differs in #{differ.join(", ")}: " \
221
- "`rails_get_schema(table:\"#{data[:table_name]}\")` lists each."
220
+ schema = pointer("rails_get_schema", %(table:"#{data[:table_name]}"), "lists each.")
221
+ lines << "" << "Columns of `#{data[:table_name]}` in #{source}; the table differs in #{differ.join(", ")}#{schema ? ": #{schema}" : "."}"
222
222
  end
223
223
  end
224
224
  lines << ignored_columns_line(ignored) if ignored.any?
@@ -575,15 +575,12 @@ module RailsAiContext
575
575
  end
576
576
  end
577
577
 
578
- # Cross-reference hints - guide AI to related tools
579
- hints = []
580
- hints << "`rails_get_schema(table:\"#{data[:table_name]}\")` for columns/indexes" if data[:table_name]
581
578
  # Only a controller the app has: the conventional name is a guess, and
582
579
  # a hint naming a class that does not exist sends the reader nowhere.
583
580
  controller_name = Payload.controller_for_route_key(cached_context, name.underscore.pluralize)&.first
584
- hints << "`rails_get_controllers(controller:\"#{controller_name}\")` for actions" if controller_name
585
- hints << "`rails_analyze_feature(feature:\"#{name}\")` for full-stack view"
586
- lines << "" << "_Next: #{hints.join(' | ')}_"
581
+ add_next_hint(lines, (pointer("rails_get_schema", %(table:"#{data[:table_name]}"), "for columns/indexes") if data[:table_name]),
582
+ (pointer("rails_get_controllers", %(controller:"#{controller_name}"), "for actions") if controller_name),
583
+ pointer("rails_analyze_feature", %(feature:"#{name}"), "for full-stack view"))
587
584
 
588
585
  lines.join("\n")
589
586
  end
@@ -22,7 +22,7 @@ module RailsAiContext
22
22
  )
23
23
 
24
24
  guide_row(
25
- order: 18,
25
+ order: 17,
26
26
  mcp: "rails_get_partial_interface(partial:\"X\")",
27
27
  cli_args: "partial=X",
28
28
  summary: "Partial locals contract: what to pass + usage examples"
@@ -79,7 +79,7 @@ module RailsAiContext
79
79
  unless located
80
80
  available = view_dirs.flat_map { |dir| find_available_partials(dir, root) }.uniq.sort.first(30)
81
81
  return not_found_response("Partial", partial, available,
82
- recovery_tool: "Call rails_get_view(detail:\"summary\") to see all views and partials", note: static_engine_views_note)
82
+ recovery_tool: call_pointer("rails_get_view", %(detail:"summary"), "to see all views and partials"), note: static_engine_views_note)
83
83
  end
84
84
 
85
85
  file_path = located.realpath
@@ -202,8 +202,7 @@ module RailsAiContext
202
202
  lines << RailsAiContext::Redaction.redact_source(source, path: relative_path)
203
203
  lines << "```"
204
204
  else
205
- lines << ""
206
- lines << "_Next: `rails_get_view(path:\"#{partial_name}\")` for full file content_"
205
+ add_next_hint(lines, pointer("rails_get_view", %(path:"#{partial_name}"), "for full file content"))
207
206
  end
208
207
 
209
208
  text_response(lines.join("\n"))