rails-ai-context 5.32.2 → 5.33.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 (188) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1287 -0
  3. data/CONTEXT.md +26 -2
  4. data/README.md +13 -9
  5. data/Rakefile +6 -0
  6. data/app/controllers/rails_ai_context/mcp_controller.rb +114 -84
  7. data/docs/ARCHITECTURE.md +6 -4
  8. data/docs/CLI.md +79 -6
  9. data/docs/COMPATIBILITY.md +65 -1
  10. data/docs/CONFIGURATION.md +30 -11
  11. data/docs/CUSTOM_TOOLS.md +2 -1
  12. data/docs/FAQ.md +16 -6
  13. data/docs/GUIDE.md +117 -71
  14. data/docs/INTROSPECTORS.md +8 -7
  15. data/docs/QUICKSTART.md +3 -3
  16. data/docs/RECIPES.md +1 -1
  17. data/docs/SECURITY.md +109 -25
  18. data/docs/SETUP.md +30 -6
  19. data/docs/STANDALONE.md +10 -3
  20. data/docs/TOOLS.md +145 -34
  21. data/docs/TROUBLESHOOTING.md +90 -12
  22. data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
  23. data/docs/adr/0005-workspace-mcp-entries.md +39 -0
  24. data/exe/rails-ai-context +733 -104
  25. data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
  26. data/lib/rails_ai_context/ast_cache.rb +7 -0
  27. data/lib/rails_ai_context/base_mixins.rb +8 -8
  28. data/lib/rails_ai_context/boot_manager.rb +22 -3
  29. data/lib/rails_ai_context/change_watch.rb +14 -11
  30. data/lib/rails_ai_context/cli/app_root.rb +401 -0
  31. data/lib/rails_ai_context/cli/entry_boot.rb +53 -20
  32. data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
  33. data/lib/rails_ai_context/code_reloader.rb +115 -1
  34. data/lib/rails_ai_context/concern_membership.rb +3 -1
  35. data/lib/rails_ai_context/concern_paths.rb +8 -3
  36. data/lib/rails_ai_context/configuration.rb +41 -9
  37. data/lib/rails_ai_context/context_file_report.rb +19 -2
  38. data/lib/rails_ai_context/database_file.rb +26 -0
  39. data/lib/rails_ai_context/doctor.rb +1122 -184
  40. data/lib/rails_ai_context/engine.rb +11 -1
  41. data/lib/rails_ai_context/erb_source.rb +19 -0
  42. data/lib/rails_ai_context/executable.rb +21 -0
  43. data/lib/rails_ai_context/facts_formatter.rb +5 -2
  44. data/lib/rails_ai_context/file_walk.rb +61 -10
  45. data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
  46. data/lib/rails_ai_context/fingerprinter.rb +40 -12
  47. data/lib/rails_ai_context/gem_lock.rb +176 -26
  48. data/lib/rails_ai_context/git_ignore.rb +22 -0
  49. data/lib/rails_ai_context/install/ai_tool.rb +31 -5
  50. data/lib/rails_ai_context/install/cleanup.rb +21 -0
  51. data/lib/rails_ai_context/install/program.rb +209 -35
  52. data/lib/rails_ai_context/install/selection_record.rb +258 -97
  53. data/lib/rails_ai_context/install/surface.rb +6 -1
  54. data/lib/rails_ai_context/install/validation_hook.rb +282 -0
  55. data/lib/rails_ai_context/install/workspace.rb +166 -0
  56. data/lib/rails_ai_context/install_mode.rb +71 -13
  57. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
  58. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
  59. data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
  60. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
  61. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
  62. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
  63. data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
  65. data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
  66. data/lib/rails_ai_context/introspectors/auth_introspector.rb +10 -3
  67. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
  68. data/lib/rails_ai_context/introspectors/controller_introspector.rb +56 -8
  69. data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
  70. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +14 -2
  71. data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
  72. data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
  73. data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
  74. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
  75. data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
  76. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
  77. data/lib/rails_ai_context/introspectors/gem_introspector.rb +2 -1
  78. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
  79. data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
  80. data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
  81. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +2 -2
  82. data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
  83. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +46 -1
  84. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
  85. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +3 -1
  86. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +14 -3
  87. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
  88. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
  89. data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
  90. data/lib/rails_ai_context/introspectors/migration_replay.rb +2 -2
  91. data/lib/rails_ai_context/introspectors/model_introspector.rb +91 -5
  92. data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
  93. data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
  94. data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
  95. data/lib/rails_ai_context/introspectors/schema_introspector.rb +57 -4
  96. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
  97. data/lib/rails_ai_context/introspectors/source_scan.rb +13 -9
  98. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
  99. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +130 -11
  100. data/lib/rails_ai_context/introspectors/view_introspector.rb +3 -3
  101. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
  102. data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
  103. data/lib/rails_ai_context/live_reload.rb +20 -20
  104. data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
  105. data/lib/rails_ai_context/mcp_edge.rb +185 -32
  106. data/lib/rails_ai_context/middleware.rb +5 -1
  107. data/lib/rails_ai_context/migration_status.rb +88 -25
  108. data/lib/rails_ai_context/output_guard.rb +48 -2
  109. data/lib/rails_ai_context/package_json.rb +1 -1
  110. data/lib/rails_ai_context/path_resolver.rb +46 -13
  111. data/lib/rails_ai_context/payload.rb +55 -7
  112. data/lib/rails_ai_context/pending_migrations.rb +5 -1
  113. data/lib/rails_ai_context/portable_path.rb +3 -2
  114. data/lib/rails_ai_context/presets.rb +15 -11
  115. data/lib/rails_ai_context/redaction.rb +254 -51
  116. data/lib/rails_ai_context/resources.rb +55 -20
  117. data/lib/rails_ai_context/route_coverage.rb +8 -0
  118. data/lib/rails_ai_context/safe_file.rb +14 -1
  119. data/lib/rails_ai_context/safe_path.rb +101 -4
  120. data/lib/rails_ai_context/schema_adapter.rb +8 -0
  121. data/lib/rails_ai_context/schema_version.rb +9 -0
  122. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +86 -32
  123. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
  124. data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
  125. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
  126. data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
  127. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +17 -15
  128. data/lib/rails_ai_context/serializers/guide_examples.rb +141 -0
  129. data/lib/rails_ai_context/serializers/markdown_serializer.rb +142 -29
  130. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +36 -14
  131. data/lib/rails_ai_context/serializers/section_facts.rb +35 -1
  132. data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
  133. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
  134. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
  135. data/lib/rails_ai_context/server.rb +304 -71
  136. data/lib/rails_ai_context/tasks/rails_ai_context.rake +112 -43
  137. data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
  138. data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
  139. data/lib/rails_ai_context/tools/base_tool.rb +352 -41
  140. data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
  141. data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
  142. data/lib/rails_ai_context/tools/diagnose.rb +218 -11
  143. data/lib/rails_ai_context/tools/generate_test.rb +339 -39
  144. data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
  145. data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
  146. data/lib/rails_ai_context/tools/get_concern.rb +91 -27
  147. data/lib/rails_ai_context/tools/get_config.rb +13 -4
  148. data/lib/rails_ai_context/tools/get_context.rb +71 -14
  149. data/lib/rails_ai_context/tools/get_controllers.rb +157 -40
  150. data/lib/rails_ai_context/tools/get_conventions.rb +26 -4
  151. data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
  152. data/lib/rails_ai_context/tools/get_env.rb +18 -9
  153. data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
  154. data/lib/rails_ai_context/tools/get_gems.rb +1 -1
  155. data/lib/rails_ai_context/tools/get_helper_methods.rb +9 -5
  156. data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
  157. data/lib/rails_ai_context/tools/get_job_pattern.rb +2 -1
  158. data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
  159. data/lib/rails_ai_context/tools/get_model_details.rb +16 -6
  160. data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
  161. data/lib/rails_ai_context/tools/get_routes.rb +19 -6
  162. data/lib/rails_ai_context/tools/get_schema.rb +51 -18
  163. data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
  164. data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
  165. data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
  166. data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
  167. data/lib/rails_ai_context/tools/get_view.rb +14 -14
  168. data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
  169. data/lib/rails_ai_context/tools/onboard.rb +46 -6
  170. data/lib/rails_ai_context/tools/performance_check.rb +4 -2
  171. data/lib/rails_ai_context/tools/query.rb +914 -86
  172. data/lib/rails_ai_context/tools/read_logs.rb +69 -15
  173. data/lib/rails_ai_context/tools/review_changes.rb +204 -43
  174. data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
  175. data/lib/rails_ai_context/tools/safe_call.rb +35 -14
  176. data/lib/rails_ai_context/tools/search_code.rb +243 -67
  177. data/lib/rails_ai_context/tools/search_docs.rb +3 -3
  178. data/lib/rails_ai_context/tools/security_scan.rb +141 -31
  179. data/lib/rails_ai_context/tools/session_context.rb +38 -4
  180. data/lib/rails_ai_context/tools/validate.rb +259 -40
  181. data/lib/rails_ai_context/tools/validate_semantics.rb +129 -55
  182. data/lib/rails_ai_context/version.rb +1 -1
  183. data/lib/rails_ai_context/vfs.rb +67 -40
  184. data/lib/rails_ai_context/view_file.rb +41 -3
  185. data/lib/rails_ai_context/watcher.rb +51 -13
  186. data/lib/rails_ai_context.rb +21 -0
  187. data/server.json +1 -1
  188. metadata +13 -1
@@ -56,18 +56,22 @@ module RailsAiContext
56
56
  key = Payload.find_controller(ctx, controller) || controller
57
57
  info = controllers[key]
58
58
  unless info
59
+ return text_response(application_controller_answer(ctx, controllers)) if key.casecmp?("ApplicationController")
60
+
59
61
  recovery = "Call rails_get_controllers(detail:\"summary\") to see all controllers"
60
62
  return not_found_response("Controller", controller, app_controller_names,
61
63
  recovery_tool: data[:unread_engine] ? "#{unread_engine_line(data)} #{recovery}" : recovery)
62
64
  end
63
65
  return text_response("Error inspecting #{key}: #{info[:error]}") if info[:error]
64
66
 
67
+ chosen = resolution_note(controller, key)
65
68
  # Specific action - return source code
66
69
  if action
67
- return format_action_source(key, info, action)
70
+ answer = format_action_source(key, info, action)
71
+ return chosen ? text_response("#{chosen}\n\n#{answer.content.first[:text]}") : answer
68
72
  end
69
73
 
70
- return text_response(format_controller(key, info, ctx))
74
+ return text_response([ chosen, format_controller(key, info, ctx) ].compact.join("\n\n"))
71
75
  end
72
76
 
73
77
  app_controllers = Payload.app_controllers(ctx)
@@ -86,7 +90,7 @@ module RailsAiContext
86
90
  # Listing mode
87
91
  case detail
88
92
  when "summary"
89
- lines = listing_header(page, data)
93
+ lines = listing_header(page, data, action)
90
94
  paginated_names.each do |name|
91
95
  info = app_controllers[name]
92
96
  action_count = info[:actions]&.size || 0
@@ -97,7 +101,7 @@ module RailsAiContext
97
101
  text_response(lines.join("\n"))
98
102
 
99
103
  when "standard"
100
- lines = listing_header(page, data)
104
+ lines = listing_header(page, data, action)
101
105
  paginated_names.each do |name|
102
106
  info = app_controllers[name]
103
107
  lines << "- **#{name}** - #{Serializers::SectionFacts.actions_phrase(info)}"
@@ -106,7 +110,7 @@ module RailsAiContext
106
110
  text_response(lines.join("\n"))
107
111
 
108
112
  when "full"
109
- lines = listing_header(page, data)
113
+ lines = listing_header(page, data, action)
110
114
 
111
115
  # Group sibling controllers that share the same parent and identical structure
112
116
  paginated_ctrl = app_controllers.select { |k, _| paginated_names.include?(k) }
@@ -177,8 +181,70 @@ module RailsAiContext
177
181
  )
178
182
  end
179
183
 
180
- private_class_method def self.listing_header(page, data)
181
- [ "# Controllers (#{page[:total]})", "", *(data[:unread_engine] ? [ unread_engine_line(data), "" ] : []) ]
184
+ private_class_method def self.listing_header(page, data, action = nil)
185
+ lines = [ "# Controllers (#{page[:total]})", "" ]
186
+ # An action is one controller's, and the listing used to come back as
187
+ # though none had been asked for.
188
+ lines.push("_`action:\"#{action.to_s.truncate(60)}\"` names an action of one controller, so it needs `controller:` too; this is the listing._", "") if action
189
+ lines.push(unread_engine_line(data), "") if data[:unread_engine]
190
+ lines
191
+ end
192
+
193
+ # The formats the controller answers: its respond_to blocks, and a
194
+ # template of another format beside its views, which Rails renders for
195
+ # that format with no respond_to at all: bazaar's products/index.json.jbuilder
196
+ # answers products.json, yet the line read "html, turbo_stream". A
197
+ # plain html-only controller gets no line.
198
+ private_class_method def self.formats_phrase(info, ctx, name)
199
+ declared = Array(info[:respond_to_formats]).map(&:to_s)
200
+ route_key = Payload.controller_route_key(ctx, name)
201
+ templates = RailsAiContext::PathResolver.view_dirs(rails_app.root.to_s).flat_map do |dir|
202
+ Dir.glob(File.join(dir, route_key, "*")).filter_map do |path|
203
+ base = File.basename(path)
204
+ parts = base.split(".")
205
+ next if base.start_with?("_") || parts.size < 3 || !File.file?(path)
206
+
207
+ [ parts[-2].sub(/\+.*\z/, ""), base ]
208
+ end
209
+ end
210
+ extra = templates.reject { |format, _| declared.include?(format) || format == "html" }
211
+ .group_by(&:first).map { |format, found| "#{format} (#{found.map(&:last).sort.join(', ')})" }
212
+ shown = declared + extra
213
+ shown.any? ? shown.join(", ") : nil
214
+ rescue => e
215
+ RailsAiContext.debug_fail(e, nil, label: "formats_phrase")
216
+ end
217
+
218
+ # A bare name that matched a namespaced controller: `UsersController`
219
+ # answered for Admin::UsersController without a word that it had.
220
+ private_class_method def self.resolution_note(asked, key)
221
+ return nil unless key.include?("::") && !asked.to_s.include?("::") && !asked.to_s.include?("/")
222
+
223
+ "_There is no top-level `#{asked.to_s.truncate(60)}`; this is `#{key}`, the controller that name matches._"
224
+ end
225
+
226
+ # ApplicationController is every controller's base and no listed
227
+ # controller of its own, so asking for it answered "not found" though
228
+ # the app has one. What it declares is read off a controller that
229
+ # inherits it.
230
+ private_class_method def self.application_controller_answer(ctx, controllers)
231
+ root = rails_app.root.to_s
232
+ child = controllers.keys.sort_by { |name| [ name.count(":"), name ] }
233
+ .find { |name| controllers[name].is_a?(Hash) && controllers[name][:parent_class].to_s.delete_prefix("::") == "ApplicationController" }
234
+ lines = [ "# ApplicationController", "" ]
235
+ unless child
236
+ lines << "The base the app's controllers inherit from. No controller read here inherits it directly, so what it declares is not read."
237
+ return lines.join("\n")
238
+ end
239
+
240
+ lines << "The base the app's controllers inherit from, not listed as a controller of its own. What it declares reaches every controller that inherits it; as #{child} reads it:"
241
+ filters = RailsAiContext::ActionFilters.for_controller(ctx, child, root: root)[:inherited].select { |f| f[:from].to_s == "ApplicationController" }
242
+ lines.push("", "## Filters", *filters.map { |f| filter_line(f) }) if filters.any?
243
+ settings = RailsAiContext::Introspectors::ControllerSettings.resolve(ctx, child, root: root)[:settings].select { |s| s[:from].to_s == "ApplicationController" }
244
+ lines.push("", "## Settings", *settings.map { |s| "- `#{s[:text]}`#{" _(through #{s[:via]})_" if s[:via]}" }) if settings.any?
245
+ lines.push("", "_It declares no filter or setting the controllers below it inherit._") if filters.empty? && settings.empty?
246
+ lines.push("", "_Next: `rails_get_controllers(controller:\"#{child}\")` for a controller that inherits it_")
247
+ lines.join("\n")
182
248
  end
183
249
 
184
250
  private_class_method def self.unread_engine_line(data)
@@ -225,11 +291,21 @@ module RailsAiContext
225
291
  actions = info[:actions] || []
226
292
  # Case-insensitive action lookup for consistency with other tools
227
293
  action_name = actions.find { |a| a.to_s.downcase == action_name.to_s.downcase }&.to_s || action_name.to_s
294
+ carried = RailsAiContext::Payload.controller_file(cached_context, controller_name)
295
+ # Rails counts a public method a concern or a parent controller gives
296
+ # among this controller's actions, though the listing takes one only
297
+ # where a route names it.
298
+ unlisted = nil
228
299
  unless actions.map(&:to_s).include?(action_name)
229
- return empty_response("Action '#{action_name}' not found in #{controller_name}. Available: #{actions.join(', ')}")
300
+ unlisted = RailsAiContext::Introspectors::ActionSource.unlisted_action(rails_app.root, controller_name, action_name,
301
+ file: carried, controllers: RailsAiContext::Payload.controllers(cached_context))
302
+ unless unlisted
303
+ return empty_response("Action '#{echo_input(action_name)}' not found in #{controller_name}. Available: #{actions.join(', ')}")
304
+ end
305
+
306
+ action_name = unlisted[:name]
230
307
  end
231
308
 
232
- carried = RailsAiContext::Payload.controller_file(cached_context, controller_name)
233
309
  source_path = carried ? rails_app.root.join(carried) : nil
234
310
  source = source_path && safe_read(source_path.to_s)
235
311
 
@@ -237,11 +313,19 @@ module RailsAiContext
237
313
  root: rails_app.root.to_s)
238
314
 
239
315
  # Extract source code with line numbers
240
- source_with_lines = source && extract_method_with_lines(source_path, action_name, source: source,
241
- owner: controller_name)
316
+ source_with_lines = unlisted || (source && extract_method_with_lines(source_path, action_name, source: source,
317
+ owner: controller_name))
318
+ # An action from a concern or a parent controller has no def in this file.
319
+ source_with_lines ||= RailsAiContext::Introspectors::ActionSource.find(rails_app.root, controller_name,
320
+ action_name, file: carried, controllers: RailsAiContext::Payload.controllers(cached_context))
321
+ elsewhere = source_with_lines&.dig(:file)
322
+ elsewhere = nil if elsewhere == carried
242
323
 
243
324
  lines = [ "# #{controller_name}##{action_name}", "" ]
244
325
  lines << "**File:** `#{carried}`" if carried
326
+ parent = source_with_lines&.dig(:owner)
327
+ lines << "**Inherited:** from #{parent}, defined in `#{source_with_lines[:file]}`." if parent && parent != controller_name
328
+ lines << "**Not listed:** #{RailsAiContext::Introspectors::ActionSource::UNLISTED_NOTE}" if unlisted
245
329
 
246
330
  if applicable.values.any?(&:any?)
247
331
  lines << "" << "## Applicable Filters"
@@ -250,8 +334,9 @@ module RailsAiContext
250
334
  end
251
335
 
252
336
  if source_with_lines
253
- lines << "" << "## Source (lines #{source_with_lines[:start_line]}-#{source_with_lines[:end_line]})"
254
- lines << "```ruby" << source_with_lines[:code] << "```"
337
+ span = "lines #{source_with_lines[:start_line]}-#{source_with_lines[:end_line]}"
338
+ lines << "" << "## Source (#{elsewhere ? "`#{elsewhere}`, #{span}" : span})"
339
+ lines << "```ruby" << redacted(source_with_lines[:code], elsewhere || carried) << "```"
255
340
 
256
341
  ivars = RailsAiContext::Introspectors::ActionResolver.assigned_ivars(source_with_lines[:code])
257
342
  lines << "" << "## Instance Variables" << ivars.map { |v| "- `@#{v}`" }.join("\n") if ivars.any?
@@ -263,7 +348,7 @@ module RailsAiContext
263
348
  lines << "" << "## Private Methods Called"
264
349
  called_methods.each do |pm|
265
350
  lines << "### #{pm[:name]} (lines #{pm[:start_line]}-#{pm[:end_line]})"
266
- lines << "```ruby" << pm[:code] << "```"
351
+ lines << "```ruby" << redacted(pm[:code], carried) << "```"
267
352
  end
268
353
  end
269
354
 
@@ -271,15 +356,15 @@ module RailsAiContext
271
356
  render_map = extract_render_map(source_with_lines[:code])
272
357
  if render_map[:redirects].any? || render_map[:renders].any?
273
358
  lines << "" << "## Render Map"
274
- render_map[:redirects].each { |r| lines << "- **Redirect:** #{r}" }
275
- render_map[:renders].each { |r| lines << "- **Render:** #{r}" }
359
+ render_map[:redirects].each { |r| lines << "- **Redirect:** #{redacted(r, carried)}" }
360
+ render_map[:renders].each { |r| lines << "- **Render:** #{redacted(r, carried)}" }
276
361
  end
277
362
  if render_map[:side_effects].any?
278
363
  lines << "" << "## Side Effects"
279
364
  render_map[:side_effects].each { |s| lines << "- #{s}" }
280
365
  end
281
366
  else
282
- lines << "" << "_Could not extract source code. File: #{source_path || "not recorded for #{controller_name}"}_"
367
+ lines << "" << "_Could not extract source code. File: #{carried || "not recorded for #{controller_name}"}_"
283
368
  end
284
369
 
285
370
  # One action's answer used to carry every strong-params method in the
@@ -306,11 +391,11 @@ module RailsAiContext
306
391
  sp[:hashes]&.each { |h| lines << "- hash: `#{h}: {}`" }
307
392
  end
308
393
  body = extract_method_with_lines(source_path, sp[:name], source: source, owner: controller_name)
309
- lines << "```ruby" << body[:code] << "```" if body
394
+ lines << "```ruby" << redacted(body[:code], carried) << "```" if body
310
395
  else
311
396
  body = extract_method_with_lines(source_path, sp, source: source, owner: controller_name)
312
397
  if body
313
- lines << "```ruby" << body[:code] << "```"
398
+ lines << "```ruby" << redacted(body[:code], carried) << "```"
314
399
  else
315
400
  lines << "- `#{sp}`"
316
401
  end
@@ -394,32 +479,13 @@ module RailsAiContext
394
479
 
395
480
  # Extract render map from action source: redirects, renders, and side effects
396
481
  private_class_method def self.extract_render_map(code)
397
- redirects = []
398
- renders = []
482
+ redirects, renders = render_and_redirect_calls(code)
399
483
  side_effects = []
400
484
  enqueues = Introspectors::SourceCalls.enqueue_calls(code, enqueue_helpers).group_by { |hit| hit[:line] }
401
485
 
402
486
  code.each_line.with_index(1) do |line, number|
403
487
  stripped = line.strip
404
488
 
405
- # Detect redirect_to calls
406
- if (m = stripped.match(/redirect_to\s+(.+)/))
407
- target = m[1].sub(/\s*,\s*(status|notice|alert|flash):.*/, "")
408
- desc = "redirect_to #{target.strip}"
409
- flash_parts = []
410
- flash_parts << "notice: #{Regexp.last_match(1)}" if stripped.match(/notice:\s*("[^"]*"|'[^']*'|[^,)]+)/)
411
- flash_parts << "alert: #{Regexp.last_match(1)}" if stripped.match(/alert:\s*("[^"]*"|'[^']*'|[^,)]+)/)
412
- desc += " (#{flash_parts.join(', ')})" if flash_parts.any?
413
- redirects << desc
414
- end
415
-
416
- # Detect render calls
417
- if (m = stripped.match(/render\s+(.+)/))
418
- render_args = m[1]
419
- desc = "render #{render_args.sub(/\s*\}?\s*$/, "").strip}"
420
- renders << desc
421
- end
422
-
423
489
  # Detect side effects
424
490
  if stripped.match?(/\.save[!]?(\s|\(|$)/)
425
491
  obj = stripped.match(/(\S+)\.save/)&.send(:[], 1) || "object"
@@ -447,6 +513,56 @@ module RailsAiContext
447
513
  RailsAiContext.debug_fail(e, { redirects: [], renders: [], side_effects: [] }, label: "extract_render_map")
448
514
  end
449
515
 
516
+ # Each render and redirect_to call as the source writes its arguments.
517
+ # Lines were matched one at a time and their ends trimmed, which cut the
518
+ # brace closing a block in `render json: accounts.map { ... }` and the
519
+ # parenthesis closing `t(".created")` in a redirect's notice.
520
+ private_class_method def self.render_and_redirect_calls(code)
521
+ redirects = []
522
+ renders = []
523
+ tree = RailsAiContext::AstCache.parse_string(code).value
524
+ action = tree.statements.body.first.then { |node| node.name if node.is_a?(Prism::DefNode) }
525
+ visit = lambda do |node|
526
+ if node.is_a?(Prism::CallNode) && node.receiver.nil? && node.arguments
527
+ case node.name
528
+ when :redirect_to then redirects << redirect_text(node.arguments.arguments)
529
+ when :render then renders << "render #{node.arguments.slice.squish}"
530
+ end
531
+ elsif action && implicit_format_render?(node)
532
+ # `format.turbo_stream` with no block renders the action's own
533
+ # template for that format, a render no `render` call names.
534
+ renders << "render :#{action}, formats: :#{node.name} (implicit, from `format.#{node.name}`)"
535
+ end
536
+ node.compact_child_nodes.each(&visit)
537
+ end
538
+ visit.call(tree)
539
+ [ redirects, renders ]
540
+ end
541
+
542
+ RESPONSE_FORMATS = %i[html json xml js turbo_stream csv text].freeze
543
+
544
+ private_class_method def self.implicit_format_render?(node)
545
+ node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::LocalVariableReadNode) &&
546
+ RESPONSE_FORMATS.include?(node.name) && node.arguments.nil? && node.block.nil?
547
+ end
548
+
549
+ # `redirect_to target (notice: ..., alert: ...)`: the flash it sets, and
550
+ # none of the status or other options beside it.
551
+ private_class_method def self.redirect_text(arguments)
552
+ options, target = arguments.partition { |arg| arg.is_a?(Prism::KeywordHashNode) }
553
+ flash = options.flat_map(&:elements).filter_map do |pair|
554
+ key = pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode) ? pair.key.unescaped : nil
555
+ "#{key}: #{pair.value.slice.squish}" if %w[notice alert].include?(key)
556
+ end
557
+ "redirect_to #{target.map { |arg| arg.slice.squish }.join(", ")}#{" (#{flash.join(', ')})" if flash.any?}"
558
+ end
559
+
560
+ # Source as it is shown: every secret in it filtered, as search_code and
561
+ # get_edit_context show the same lines.
562
+ private_class_method def self.redacted(code, path)
563
+ RailsAiContext::Redaction.redact_source(code, path: path)
564
+ end
565
+
450
566
  private_class_method def self.extract_method_with_lines(file_path, method_name, source: nil, owner: nil)
451
567
  unless source
452
568
  return nil unless file_path && File.exist?(file_path)
@@ -463,7 +579,8 @@ module RailsAiContext
463
579
  lines = [ "# #{name}", "" ]
464
580
  lines << "**Parent:** `#{resolved_parent(name, info, ctx)}`" if info[:parent_class]
465
581
  lines << "**API controller:** yes" if info[:api_controller]
466
- lines << "**Formats:** #{info[:respond_to_formats].join(', ')}" if info[:respond_to_formats]&.any?
582
+ formats = formats_phrase(info, ctx, name)
583
+ lines << "**Formats:** #{formats}" if formats
467
584
  declared = RailsAiContext::Introspectors::ControllerSettings.resolve(ctx, name, root: rails_app.root.to_s)
468
585
  lines << "**Layout:** #{RailsAiContext::Introspectors::ControllerSettings.layout_phrase(declared[:layout])}" if declared[:layout]
469
586
 
@@ -192,6 +192,15 @@ module RailsAiContext
192
192
  stack
193
193
  end
194
194
 
195
+ # How a create action checks permission: the app's own `can_x?`,
196
+ # CanCanCan's `authorize!`, or Pundit's `authorize record`.
197
+ private_class_method def self.permission_style(block)
198
+ if block.match?(/\bcan_\w+\??/) then :can_method
199
+ elsif block.match?(/\bauthorize!/) then :cancan
200
+ elsif block.match?(/\bauthorize\b/) then :pundit
201
+ end
202
+ end
203
+
195
204
  # Scan controllers for app-specific authorization, flash, and error-handling patterns
196
205
  private_class_method def self.detect_app_patterns
197
206
  controllers_dir = rails_app.root.join("app", "controllers").to_s
@@ -210,10 +219,14 @@ module RailsAiContext
210
219
  html_response_count = 0
211
220
  show_only_controllers = []
212
221
  has_services = Dir.exist?(rails_app.root.join("app", "services"))
222
+ permission_styles = []
223
+ real_controllers_dir = File.realpath(controllers_dir).to_s
213
224
 
214
225
  safe_glob(controllers_dir, "**/*.rb", real_root).each do |path|
215
226
  content = RailsAiContext::SafeFile.read(path) or next
216
- controller_name = File.basename(path, ".rb")
227
+ # The path under app/controllers, so Api::V1::ProductsController is
228
+ # not a second ProductsController.
229
+ controller_name = path.delete_prefix("#{real_controllers_dir}/").delete_suffix(".rb")
217
230
 
218
231
  auth_checks.concat(content.scan(/\b(can_\w+\??)/).flatten)
219
232
  auth_denials.concat(content.scan(/redirect_to\s+.+?,\s*alert:\s*"([^"]*)"/).flatten
@@ -235,7 +248,10 @@ module RailsAiContext
235
248
  create_block = content[/def\s+create#{RailsAiContext::MethodName.definition_end("create")}.*?(?=\n\s{2}def\s|\n\s{2}private|\z)/m]
236
249
  if create_block
237
250
  flow_parts = []
238
- flow_parts << "permission check" if create_block.match?(/can_\w+\??|authorize|authorize!/)
251
+ if (style = permission_style(create_block))
252
+ flow_parts << "permission check"
253
+ permission_styles << style
254
+ end
239
255
  flow_parts << "build" if create_block.match?(/\.new\(|\.build\(|\.create\(/)
240
256
  # `create!` raises instead of returning false - it is not the
241
257
  # save-then-branch flow this skeleton describes.
@@ -305,9 +321,13 @@ module RailsAiContext
305
321
  # whichever the app's own create actions use most.
306
322
  failure_status = create_error_statuses.tally.max_by { |_, count| count }&.first || "unprocessable_entity"
307
323
 
324
+ # The check the app's own create actions make: a Pundit app
325
+ # authorizes the record, where the skeleton used to call a
326
+ # `can_[permission]?` method no Pundit app defines.
327
+ style = permission_styles.tally.max_by { |_, count| count }&.first
308
328
  sections << "```ruby"
309
329
  sections << "def create"
310
- if has_permission_check
330
+ if has_permission_check && style == :can_method
311
331
  sections << " unless current_user.can_[permission]?"
312
332
  sections << ' redirect_to [path], alert: "[limit message]"'
313
333
  sections << " return"
@@ -316,6 +336,8 @@ module RailsAiContext
316
336
  sections << " @record = current_user.[association].build([params_method])"
317
337
  else
318
338
  sections << " @record = [Model].new([params_method])"
339
+ sections << " authorize @record" if has_permission_check && style == :pundit
340
+ sections << " authorize! :create, @record" if has_permission_check && style == :cancan
319
341
  end
320
342
  if has_save
321
343
  sections << ""
@@ -340,7 +362,7 @@ module RailsAiContext
340
362
  sections << "end"
341
363
  sections << "```"
342
364
  sections << ""
343
- sections << "Detected in: #{create_flows.map { |f| f.split(':').first }.join(', ')}"
365
+ sections << "Detected in: #{create_flows.map { |f| f.split(': ').first }.join(', ')}"
344
366
  end
345
367
 
346
368
  if show_only_controllers.any?
@@ -47,33 +47,46 @@ module RailsAiContext
47
47
 
48
48
  located = RailsAiContext::SafePath.locate(file, under: rails_app.root.to_s)
49
49
  case located.refusal
50
- when :traversal, :outside then return error_response("Path not allowed: #{file}")
51
- when :sensitive then return error_response("Access denied: #{file} is a sensitive file (secrets/keys/credentials).")
50
+ when :traversal, :outside then return error_response("Path not allowed: #{echo_input(file)}")
51
+ when :sensitive then return error_response("Path not allowed: #{echo_input(file)} (sensitive file)")
52
52
  when :too_large then return text_response("File too large: #{file}")
53
53
  when :missing
54
+ root = rails_app.root.to_s
54
55
  basename = File.basename(file)
55
- candidates = Dir.glob(File.join(rails_app.root, "app", "**", basename)).first(5)
56
+ asked = File.expand_path(file, root)
57
+ # Files of that name the tool would read, never the path asked for:
58
+ # a link that resolves nowhere is not a file to retry.
59
+ candidates = RailsAiContext::FileWalk.each_file(File.join(root, "app"), root: root)
60
+ .select { |path| File.basename(path) == basename && path != asked }.sort.first(5)
56
61
  hint = if candidates.any?
57
- suggestions = candidates.map { |c| c.sub("#{rails_app.root}/", "") }
62
+ suggestions = candidates.map { |c| c.delete_prefix("#{root}/") }
58
63
  " Did you mean: #{suggestions.join(', ')}? Use the full path relative to Rails root."
59
64
  else
60
- " Use the full path relative to Rails root (e.g., 'app/models/#{basename}')."
65
+ # An example built from the name the caller gave was that same
66
+ # path back for `app/models/nope.rb`, read as a file to retry.
67
+ " No file named #{echo_input(basename)} is under app/."
61
68
  end
62
- return empty_response("File not found: #{file}.#{hint}")
69
+ return empty_response("File not found: #{echo_input(file)}.#{hint}")
63
70
  end
64
71
  real = located.realpath
65
72
 
66
73
  source_lines = (RailsAiContext::SafeFile.read(real) || "").lines
67
74
  context_lines = [ context_lines.to_i, 0 ].max
68
75
 
76
+ # Source leaving the app passes through Redaction, the whole file at once, so a window
77
+ # opening inside a PEM key still knows it is inside one. `near` is looked for in the lines
78
+ # as the caller sees them: found in the file as written, a guess at a filtered value's
79
+ # start brought back a window where a wrong guess brought "not found".
80
+ redacted = RailsAiContext::Redaction.redact_source_lines(source_lines.map(&:rstrip), path: file)
81
+
69
82
  # Find all matching lines
70
83
  matches = []
71
- source_lines.each_with_index do |line, idx|
84
+ redacted.each_with_index do |line, idx|
72
85
  matches << idx if line.include?(near)
73
86
  end
74
87
 
75
88
  if matches.empty?
76
- return empty_response("'#{near}' not found in #{file} (#{count_phrase(source_lines.size, "line")}).\n\nAvailable methods:\n#{extract_methods(source_lines)}")
89
+ return empty_response("'#{echo_input(near)}' not found in #{file} (#{count_phrase(source_lines.size, "line")}).\n\nAvailable methods:\n#{extract_methods(source_lines)}")
77
90
  end
78
91
 
79
92
  # Build context window around first match
@@ -94,9 +107,6 @@ module RailsAiContext
94
107
  end_idx = [ end_idx, method_end ].max
95
108
  end
96
109
 
97
- # Source leaving the app passes through Redaction, the whole file at once, so a window
98
- # opening inside a PEM key still knows it is inside one.
99
- redacted = RailsAiContext::Redaction.redact_source_lines(source_lines.map(&:rstrip), path: file)
100
110
  context_code = redacted[start_idx..end_idx].map.with_index do |line, i|
101
111
  "#{(start_idx + i + 1).to_s.rjust(4)} #{line}"
102
112
  end.join("\n")
@@ -109,9 +119,12 @@ module RailsAiContext
109
119
  else ""
110
120
  end
111
121
 
112
- # Detect enclosing class and method for context
113
- class_name = source_lines[0..match_idx].reverse.find { |l| l.match?(/\A\s*(class|module)\s/) }&.strip&.sub(/\s*<.*/, "")
114
- method_name = method_start ? source_lines[method_start].strip.sub(/\s*\(.*/, "").sub(/\Adef\s+/, "def ") : nil
122
+ # Detect enclosing class and method for context, named from the redacted lines: an endless
123
+ # method's line is its whole body (`def headers = { "Authorization" => "Bearer ..." }`), and
124
+ # the label keeps its name alone.
125
+ class_idx = match_idx.downto(0).find { |i| source_lines[i].match?(/\A\s*(class|module)\s/) }
126
+ class_name = class_idx && redacted[class_idx].strip.sub(/\s*<.*/, "")
127
+ method_name = method_start ? redacted[method_start].strip.sub(/\s*\(.*/, "").sub(/\s+=\s.*/, "").sub(/\Adef\s+/, "def ") : nil
115
128
  context_label = [ class_name, method_name ].compact.join(" > ")
116
129
 
117
130
  output = [ "# #{file} (lines #{start_idx + 1}-#{end_idx + 1} of #{source_lines.size})", "" ]
@@ -98,9 +98,10 @@ module RailsAiContext
98
98
 
99
99
  # Named in the answer, because a name missing from it is otherwise
100
100
  # indistinguishable from a name the app does not read.
101
- SCAN_NOTE = "_Scanned `app`, `config` and `lib` for `.rb`, `.rake`, `.erb` and config `.yml`, plus `config.ru`, `db/seeds` and the Ruby scripts in `bin`. " \
102
- "Config YAML on `sensitive_patterns` (config/database.yml) is read for the ENV names in its ERB tags only; " \
103
- "credentials, keys and the rest are never read._"
101
+ SCAN_NOTE = "_Scanned `app`, `config` and `lib` for `.rb`, `.rake`, `.erb` and config `.yml`, plus `config.ru`, `db/seeds` and the Ruby scripts in `bin`; " \
102
+ "a YAML comment line is skipped, since what its ERB tag writes is thrown away. " \
103
+ "Config YAML on `sensitive_patterns` (config/database.yml, config/cable.yml, config/storage.yml and the rest) is read for the ENV names in its ERB tags only, " \
104
+ "so a default there is listed as not read; credentials, keys and the rest are never read._"
104
105
 
105
106
  private_class_method def self.format_standard(env_vars, env_example, deploy_and_settings, external_services, credentials_keys, encrypted_columns)
106
107
  lines = [ "# Environment Configuration", "" ]
@@ -127,6 +128,9 @@ module RailsAiContext
127
128
  " (#{DEFAULTS_DIFFER}; `detail:\"full\"` names each)"
128
129
  elsif defaults.size == 1 && defaults.first.is_a?(String)
129
130
  " (default: `#{defaults.first}`)"
131
+ elsif sites.any? && sites.all? { |v| v[:default_unread] }
132
+ # Read as no default at all, which for a fetch means a KeyError.
133
+ " (has a default, not read: #{sites.map { |v| "`#{v[:file]}`" }.uniq.join(', ')} is on `sensitive_patterns`)"
130
134
  else
131
135
  ""
132
136
  end
@@ -207,10 +211,13 @@ module RailsAiContext
207
211
  if env_vars.any?
208
212
  lines << "## Environment Variables by Category"
209
213
 
210
- # Build a map: var_name -> { details from all files }
214
+ # Build a map: var_name -> { details from all files }. The scan
215
+ # keys each file by its realpath, which under a symlinked root (a
216
+ # macOS /tmp, /var) starts other than the root as the app names it.
217
+ real_root = File.realpath(root)
211
218
  var_details = {}
212
219
  env_vars.sort_by { |file, _| file }.each do |file, vars|
213
- relative = file.sub("#{root}/", "")
220
+ relative = file.delete_prefix("#{real_root}/")
214
221
  vars.each do |v|
215
222
  var_details[v[:name]] ||= { files: [], defaults: [] }
216
223
  var_details[v[:name]][:files] << { file: relative, line: v[:line], default: v[:default],
@@ -238,8 +245,8 @@ module RailsAiContext
238
245
  # most needs, since it raises KeyError when the variable is unset.
239
246
  file_locations = v[:files].map { |f|
240
247
  at = f[:line] ? "#{f[:file]}:#{f[:line]}" : f[:file]
241
- next at unless disagree?(v[:files])
242
248
  next "#{at} default not read" if f[:default_unread]
249
+ next at unless disagree?(v[:files])
243
250
 
244
251
  case f[:default]
245
252
  when String then "#{at} default: `#{f[:default]}`"
@@ -783,10 +790,12 @@ module RailsAiContext
783
790
  groups.sort_by { |k, _| CATEGORY_ORDER.index(k) || 99 }
784
791
  end
785
792
 
786
- # Every site each variable is read at.
793
+ # Every site each variable is read at, with the file it is in.
787
794
  private_class_method def self.sites_by_name(env_vars)
788
- env_vars.each_value.with_object(Hash.new { |h, k| h[k] = [] }) do |vars, found|
789
- vars.each { |v| found[v[:name]] << v }
795
+ # The scan keys each file by its realpath.
796
+ root = "#{File.realpath(rails_app.root.to_s)}/"
797
+ env_vars.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(path, vars), found|
798
+ vars.each { |v| found[v[:name]] << v.merge(file: path.to_s.delete_prefix(root)) }
790
799
  end
791
800
  end
792
801
 
@@ -329,7 +329,9 @@ module RailsAiContext
329
329
  end
330
330
 
331
331
  if turbo
332
- broadcasts = turbo[:model_broadcasts]&.size || 0
332
+ # The broadcast_*_to calls as well as the model macros: bazaar's
333
+ # three explicit broadcasts went uncounted beside its one macro.
334
+ broadcasts = Array(turbo[:model_broadcasts]).size + Array(turbo[:explicit_broadcasts]).size
333
335
  frames = turbo[:turbo_frames]&.size || 0
334
336
  parts = []
335
337
  parts << count_phrase(broadcasts, "broadcast") if broadcasts > 0
@@ -90,7 +90,7 @@ module RailsAiContext
90
90
  elsif page[:total].zero?
91
91
  all_cats = (gems[:notable_gems] || []).map { |g| g[:category] }.uniq.sort
92
92
  hint = all_cats.any? ? " Available categories: #{all_cats.join(', ')}" : ""
93
- lines << "_No notable gems found#{" in category '#{category}'" unless category == 'all'}.#{hint}_"
93
+ lines << "_No notable gems found#{" in category '#{echo_input(category)}'" unless category == 'all'}.#{hint}_"
94
94
  end
95
95
  # An empty page past the end is not an empty app. Saying "no notable
96
96
  # gems found" above "No items at offset 9999. Total: 13." contradicts
@@ -204,11 +204,15 @@ module RailsAiContext
204
204
  end
205
205
  end
206
206
 
207
- # Cross-reference hints
208
- controller_name = underscore.split("/").last
209
- lines << ""
210
- lines << "_Next: `rails_get_view(controller:\"#{controller_name}\")` for views"
211
- lines << " | `rails_get_controllers(controller:\"#{controller_name.camelize}Controller\")` for controller_"
207
+ # Cross-reference hints, to the views and the controller the helper is
208
+ # named for, and only those the app has: ApplicationHelper has no
209
+ # controller of its own to send the reader to.
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)) }
213
+ 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?
212
216
 
213
217
  text_response(lines.join("\n"))
214
218
  end
@@ -132,7 +132,7 @@ module RailsAiContext
132
132
  lines << "- **Fallbacks:** #{Array(fallbacks[locale.to_sym] || fallbacks[locale]).join(', ')}"
133
133
  end
134
134
 
135
- render_file_list(lines, page, "Files for #{locale}", "_No locale files found for '#{locale}'._")
135
+ render_file_list(lines, page, "Files for #{echo_input(locale)}", "_No locale files found for '#{echo_input(locale)}'._")
136
136
  text_response(lines.join("\n"))
137
137
  end
138
138
 
@@ -549,9 +549,10 @@ module RailsAiContext
549
549
  body = perform && Introspectors::ActionResolver.body_of(source, perform)
550
550
  return [] unless body
551
551
 
552
- body[:code].lines.map(&:strip).select { |line|
552
+ guards = body[:code].lines.map(&:strip).select { |line|
553
553
  line.match?(/\Areturn\s+(if|unless)\b/) || (line.match?(/\Areturn\b/) && line.length < 120)
554
554
  }.first(10)
555
+ RailsAiContext::Redaction.redact_source_lines(guards)
555
556
  end
556
557
 
557
558
  private_class_method def self.extract_broadcasts(source)
@@ -29,8 +29,8 @@ module RailsAiContext
29
29
 
30
30
  guide_row(
31
31
  order: 41,
32
- mcp: "rails_get_mailers(mailer:\"UserMailer\")",
33
- cli_args: "mailer=UserMailer",
32
+ mcp: "rails_get_mailers(mailer:\"X\")",
33
+ cli_args: "mailer=X",
34
34
  summary: "Mailer classes with delivery actions and delivery method, mailboxes and their routing"
35
35
  )
36
36
 
@@ -76,7 +76,7 @@ module RailsAiContext
76
76
  elsif page[:total].zero?
77
77
  # An empty page past the end is not an app without mailers, and the
78
78
  # hint below already says where the end is.
79
- lines << "_No mailers found#{" matching '#{mailer}'" if mailer}._"
79
+ lines << "_No mailers found#{" matching '#{echo_input(mailer)}'" if mailer}._"
80
80
  end
81
81
 
82
82
  lines.concat(mailbox_lines(Payload.section(cached_context, :action_mailbox))) if mailer.nil? && page[:offset].zero?