rails-ai-context 5.32.2 → 5.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1491 -0
  3. data/CONTEXT.md +27 -3
  4. data/README.md +16 -12
  5. data/Rakefile +6 -0
  6. data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
  7. data/config/routes.rb +2 -3
  8. data/docs/ARCHITECTURE.md +7 -5
  9. data/docs/CLI.md +79 -6
  10. data/docs/COMPATIBILITY.md +75 -1
  11. data/docs/CONFIGURATION.md +30 -11
  12. data/docs/CUSTOM_TOOLS.md +2 -1
  13. data/docs/FAQ.md +16 -6
  14. data/docs/GUIDE.md +117 -71
  15. data/docs/INTROSPECTORS.md +10 -8
  16. data/docs/QUICKSTART.md +3 -3
  17. data/docs/RECIPES.md +1 -1
  18. data/docs/SECURITY.md +109 -25
  19. data/docs/SETUP.md +30 -6
  20. data/docs/STANDALONE.md +10 -3
  21. data/docs/TOOLS.md +152 -34
  22. data/docs/TROUBLESHOOTING.md +90 -12
  23. data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
  24. data/docs/adr/0005-workspace-mcp-entries.md +39 -0
  25. data/exe/rails-ai-context +733 -104
  26. data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
  27. data/lib/rails_ai_context/app_kind.rb +18 -5
  28. data/lib/rails_ai_context/ast_cache.rb +7 -0
  29. data/lib/rails_ai_context/base_mixins.rb +8 -8
  30. data/lib/rails_ai_context/boot_manager.rb +22 -3
  31. data/lib/rails_ai_context/change_watch.rb +14 -11
  32. data/lib/rails_ai_context/cli/app_root.rb +401 -0
  33. data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
  34. data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
  35. data/lib/rails_ai_context/code_reloader.rb +115 -1
  36. data/lib/rails_ai_context/concern_membership.rb +3 -1
  37. data/lib/rails_ai_context/concern_paths.rb +8 -3
  38. data/lib/rails_ai_context/configuration.rb +41 -9
  39. data/lib/rails_ai_context/context_file_report.rb +19 -2
  40. data/lib/rails_ai_context/database_file.rb +26 -0
  41. data/lib/rails_ai_context/doctor.rb +1122 -184
  42. data/lib/rails_ai_context/engine.rb +18 -1
  43. data/lib/rails_ai_context/erb_source.rb +19 -0
  44. data/lib/rails_ai_context/executable.rb +21 -0
  45. data/lib/rails_ai_context/facts_formatter.rb +5 -2
  46. data/lib/rails_ai_context/file_walk.rb +61 -10
  47. data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
  48. data/lib/rails_ai_context/fingerprinter.rb +40 -12
  49. data/lib/rails_ai_context/gem_lock.rb +186 -27
  50. data/lib/rails_ai_context/git_ignore.rb +22 -0
  51. data/lib/rails_ai_context/install/ai_tool.rb +31 -5
  52. data/lib/rails_ai_context/install/cleanup.rb +21 -0
  53. data/lib/rails_ai_context/install/program.rb +209 -35
  54. data/lib/rails_ai_context/install/selection_record.rb +258 -97
  55. data/lib/rails_ai_context/install/surface.rb +6 -1
  56. data/lib/rails_ai_context/install/validation_hook.rb +282 -0
  57. data/lib/rails_ai_context/install/workspace.rb +166 -0
  58. data/lib/rails_ai_context/install_mode.rb +71 -13
  59. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
  60. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
  61. data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
  63. data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
  65. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
  66. data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
  67. data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
  68. data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
  69. data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
  70. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
  71. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
  72. data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
  73. data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
  74. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
  75. data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
  76. data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
  77. data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
  78. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
  79. data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
  80. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
  81. data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
  82. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
  83. data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
  84. data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
  85. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
  86. data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
  87. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
  88. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
  90. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
  91. data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
  92. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
  93. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
  94. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
  95. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
  96. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
  97. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
  98. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
  99. data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
  100. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
  101. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
  102. data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
  103. data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
  104. data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
  105. data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
  106. data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
  107. data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
  108. data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
  109. data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
  110. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
  111. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
  112. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
  113. data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
  114. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
  115. data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
  116. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
  117. data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
  118. data/lib/rails_ai_context/live_reload.rb +20 -20
  119. data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
  120. data/lib/rails_ai_context/mcp_edge.rb +182 -32
  121. data/lib/rails_ai_context/middleware.rb +5 -1
  122. data/lib/rails_ai_context/migration_status.rb +88 -25
  123. data/lib/rails_ai_context/output_guard.rb +48 -2
  124. data/lib/rails_ai_context/package_json.rb +1 -1
  125. data/lib/rails_ai_context/path_resolver.rb +46 -13
  126. data/lib/rails_ai_context/payload.rb +92 -8
  127. data/lib/rails_ai_context/pending_migrations.rb +5 -1
  128. data/lib/rails_ai_context/portable_path.rb +3 -2
  129. data/lib/rails_ai_context/presets.rb +15 -11
  130. data/lib/rails_ai_context/redaction.rb +262 -51
  131. data/lib/rails_ai_context/resources.rb +55 -20
  132. data/lib/rails_ai_context/route_coverage.rb +8 -0
  133. data/lib/rails_ai_context/safe_file.rb +14 -1
  134. data/lib/rails_ai_context/safe_path.rb +101 -4
  135. data/lib/rails_ai_context/schema_adapter.rb +8 -0
  136. data/lib/rails_ai_context/schema_version.rb +9 -0
  137. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
  138. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
  139. data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
  140. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
  141. data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
  142. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
  143. data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
  144. data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
  145. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
  146. data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
  147. data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
  148. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
  149. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
  150. data/lib/rails_ai_context/server.rb +304 -71
  151. data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
  152. data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
  153. data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
  154. data/lib/rails_ai_context/tools/base_tool.rb +367 -46
  155. data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
  156. data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
  157. data/lib/rails_ai_context/tools/diagnose.rb +218 -11
  158. data/lib/rails_ai_context/tools/generate_test.rb +339 -39
  159. data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
  160. data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
  161. data/lib/rails_ai_context/tools/get_concern.rb +91 -27
  162. data/lib/rails_ai_context/tools/get_config.rb +13 -4
  163. data/lib/rails_ai_context/tools/get_context.rb +71 -14
  164. data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
  165. data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
  166. data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
  167. data/lib/rails_ai_context/tools/get_env.rb +18 -9
  168. data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
  169. data/lib/rails_ai_context/tools/get_gems.rb +1 -1
  170. data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
  171. data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
  172. data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
  173. data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
  174. data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
  175. data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
  176. data/lib/rails_ai_context/tools/get_routes.rb +29 -8
  177. data/lib/rails_ai_context/tools/get_schema.rb +52 -19
  178. data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
  179. data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
  180. data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
  181. data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
  182. data/lib/rails_ai_context/tools/get_view.rb +14 -14
  183. data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
  184. data/lib/rails_ai_context/tools/onboard.rb +52 -8
  185. data/lib/rails_ai_context/tools/performance_check.rb +5 -3
  186. data/lib/rails_ai_context/tools/query.rb +914 -86
  187. data/lib/rails_ai_context/tools/read_logs.rb +69 -15
  188. data/lib/rails_ai_context/tools/review_changes.rb +215 -53
  189. data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
  190. data/lib/rails_ai_context/tools/safe_call.rb +35 -14
  191. data/lib/rails_ai_context/tools/search_code.rb +243 -67
  192. data/lib/rails_ai_context/tools/search_docs.rb +4 -4
  193. data/lib/rails_ai_context/tools/security_scan.rb +143 -33
  194. data/lib/rails_ai_context/tools/session_context.rb +39 -5
  195. data/lib/rails_ai_context/tools/validate.rb +259 -40
  196. data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
  197. data/lib/rails_ai_context/version.rb +1 -1
  198. data/lib/rails_ai_context/vfs.rb +67 -40
  199. data/lib/rails_ai_context/view_file.rb +41 -3
  200. data/lib/rails_ai_context/watcher.rb +51 -13
  201. data/lib/rails_ai_context.rb +21 -0
  202. data/server.json +1 -1
  203. metadata +14 -1
@@ -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)
@@ -222,14 +288,28 @@ module RailsAiContext
222
288
  end
223
289
 
224
290
  private_class_method def self.format_action_source(controller_name, info, action_name)
291
+ # An alias is the same class: its actions are read from the controller it names.
292
+ target = info[:alias_of] && RailsAiContext::Payload.controllers(cached_context)[info[:alias_of]]
293
+ return format_action_source(info[:alias_of], target, action_name) if target.is_a?(Hash) && !target[:alias_of]
294
+
225
295
  actions = info[:actions] || []
226
296
  # Case-insensitive action lookup for consistency with other tools
227
297
  action_name = actions.find { |a| a.to_s.downcase == action_name.to_s.downcase }&.to_s || action_name.to_s
298
+ carried = RailsAiContext::Payload.controller_file(cached_context, controller_name)
299
+ # Rails counts a public method a concern or a parent controller gives
300
+ # among this controller's actions, though the listing takes one only
301
+ # where a route names it.
302
+ unlisted = nil
228
303
  unless actions.map(&:to_s).include?(action_name)
229
- return empty_response("Action '#{action_name}' not found in #{controller_name}. Available: #{actions.join(', ')}")
304
+ unlisted = RailsAiContext::Introspectors::ActionSource.unlisted_action(rails_app.root, controller_name, action_name,
305
+ file: carried, controllers: RailsAiContext::Payload.controllers(cached_context))
306
+ unless unlisted
307
+ return empty_response("Action '#{echo_input(action_name)}' not found in #{controller_name}. Available: #{actions.join(', ')}")
308
+ end
309
+
310
+ action_name = unlisted[:name]
230
311
  end
231
312
 
232
- carried = RailsAiContext::Payload.controller_file(cached_context, controller_name)
233
313
  source_path = carried ? rails_app.root.join(carried) : nil
234
314
  source = source_path && safe_read(source_path.to_s)
235
315
 
@@ -237,11 +317,19 @@ module RailsAiContext
237
317
  root: rails_app.root.to_s)
238
318
 
239
319
  # 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)
320
+ source_with_lines = unlisted || (source && extract_method_with_lines(source_path, action_name, source: source,
321
+ owner: controller_name))
322
+ # An action from a concern or a parent controller has no def in this file.
323
+ source_with_lines ||= RailsAiContext::Introspectors::ActionSource.find(rails_app.root, controller_name,
324
+ action_name, file: carried, controllers: RailsAiContext::Payload.controllers(cached_context))
325
+ elsewhere = source_with_lines&.dig(:file)
326
+ elsewhere = nil if elsewhere == carried
242
327
 
243
328
  lines = [ "# #{controller_name}##{action_name}", "" ]
244
329
  lines << "**File:** `#{carried}`" if carried
330
+ parent = source_with_lines&.dig(:owner)
331
+ lines << "**Inherited:** from #{parent}, defined in `#{source_with_lines[:file]}`." if parent && parent != controller_name
332
+ lines << "**Not listed:** #{RailsAiContext::Introspectors::ActionSource::UNLISTED_NOTE}" if unlisted
245
333
 
246
334
  if applicable.values.any?(&:any?)
247
335
  lines << "" << "## Applicable Filters"
@@ -250,8 +338,9 @@ module RailsAiContext
250
338
  end
251
339
 
252
340
  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] << "```"
341
+ span = "lines #{source_with_lines[:start_line]}-#{source_with_lines[:end_line]}"
342
+ lines << "" << "## Source (#{elsewhere ? "`#{elsewhere}`, #{span}" : span})"
343
+ lines << "```ruby" << redacted(source_with_lines[:code], elsewhere || carried) << "```"
255
344
 
256
345
  ivars = RailsAiContext::Introspectors::ActionResolver.assigned_ivars(source_with_lines[:code])
257
346
  lines << "" << "## Instance Variables" << ivars.map { |v| "- `@#{v}`" }.join("\n") if ivars.any?
@@ -263,7 +352,7 @@ module RailsAiContext
263
352
  lines << "" << "## Private Methods Called"
264
353
  called_methods.each do |pm|
265
354
  lines << "### #{pm[:name]} (lines #{pm[:start_line]}-#{pm[:end_line]})"
266
- lines << "```ruby" << pm[:code] << "```"
355
+ lines << "```ruby" << redacted(pm[:code], carried) << "```"
267
356
  end
268
357
  end
269
358
 
@@ -271,15 +360,15 @@ module RailsAiContext
271
360
  render_map = extract_render_map(source_with_lines[:code])
272
361
  if render_map[:redirects].any? || render_map[:renders].any?
273
362
  lines << "" << "## Render Map"
274
- render_map[:redirects].each { |r| lines << "- **Redirect:** #{r}" }
275
- render_map[:renders].each { |r| lines << "- **Render:** #{r}" }
363
+ render_map[:redirects].each { |r| lines << "- **Redirect:** #{redacted(r, carried)}" }
364
+ render_map[:renders].each { |r| lines << "- **Render:** #{redacted(r, carried)}" }
276
365
  end
277
366
  if render_map[:side_effects].any?
278
367
  lines << "" << "## Side Effects"
279
368
  render_map[:side_effects].each { |s| lines << "- #{s}" }
280
369
  end
281
370
  else
282
- lines << "" << "_Could not extract source code. File: #{source_path || "not recorded for #{controller_name}"}_"
371
+ lines << "" << "_Could not extract source code. File: #{carried || "not recorded for #{controller_name}"}_"
283
372
  end
284
373
 
285
374
  # One action's answer used to carry every strong-params method in the
@@ -292,9 +381,9 @@ module RailsAiContext
292
381
  lines << "" << "## Strong Params"
293
382
  action_params.each do |sp|
294
383
  if sp.is_a?(Hash)
295
- lines << "### #{sp[:name]}"
384
+ lines << "### #{sp[:name]}#{params_origin(sp)}"
296
385
  if sp[:unrestricted]
297
- lines << "- **WARNING:** `params.permit!` - all parameters allowed"
386
+ lines << "- #{UNRESTRICTED}"
298
387
  else
299
388
  lines << "- requires: `:#{sp[:requires]}`" if sp[:requires]
300
389
  lines << "- permits: #{sp[:permits].map { |p| "`:#{p}`" }.join(', ')}" if sp[:permits]&.any?
@@ -304,13 +393,14 @@ module RailsAiContext
304
393
  end
305
394
  sp[:arrays]&.each { |a| lines << "- array: `#{a}: []`" }
306
395
  sp[:hashes]&.each { |h| lines << "- hash: `#{h}: {}`" }
396
+ sp[:computed]&.each { |key, source| lines << "- nested, computed: `#{key}: #{source}`" }
307
397
  end
308
398
  body = extract_method_with_lines(source_path, sp[:name], source: source, owner: controller_name)
309
- lines << "```ruby" << body[:code] << "```" if body
399
+ lines << "```ruby" << redacted(body[:code], carried) << "```" if body
310
400
  else
311
401
  body = extract_method_with_lines(source_path, sp, source: source, owner: controller_name)
312
402
  if body
313
- lines << "```ruby" << body[:code] << "```"
403
+ lines << "```ruby" << redacted(body[:code], carried) << "```"
314
404
  else
315
405
  lines << "- `#{sp}`"
316
406
  end
@@ -371,6 +461,12 @@ module RailsAiContext
371
461
  end
372
462
  end
373
463
 
464
+ UNRESTRICTED = "**WARNING:** `params.permit!` - all parameters allowed"
465
+
466
+ private_class_method def self.params_origin(entry)
467
+ " _(from #{entry[:from]})_" if entry[:from]
468
+ end
469
+
374
470
  # One field of a permit list as Ruby spells it: `:name`, `[...]`, `key: [...]`, `key: {}` or `key: { ... }`, braced inside a list.
375
471
  private_class_method def self.permit_field_text(field, braced: false)
376
472
  return "[#{field.map { |f| permit_field_text(f, braced: true) }.join(', ')}]" if field.is_a?(Array)
@@ -394,32 +490,13 @@ module RailsAiContext
394
490
 
395
491
  # Extract render map from action source: redirects, renders, and side effects
396
492
  private_class_method def self.extract_render_map(code)
397
- redirects = []
398
- renders = []
493
+ redirects, renders = render_and_redirect_calls(code)
399
494
  side_effects = []
400
495
  enqueues = Introspectors::SourceCalls.enqueue_calls(code, enqueue_helpers).group_by { |hit| hit[:line] }
401
496
 
402
497
  code.each_line.with_index(1) do |line, number|
403
498
  stripped = line.strip
404
499
 
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
500
  # Detect side effects
424
501
  if stripped.match?(/\.save[!]?(\s|\(|$)/)
425
502
  obj = stripped.match(/(\S+)\.save/)&.send(:[], 1) || "object"
@@ -447,6 +524,56 @@ module RailsAiContext
447
524
  RailsAiContext.debug_fail(e, { redirects: [], renders: [], side_effects: [] }, label: "extract_render_map")
448
525
  end
449
526
 
527
+ # Each render and redirect_to call as the source writes its arguments.
528
+ # Lines were matched one at a time and their ends trimmed, which cut the
529
+ # brace closing a block in `render json: accounts.map { ... }` and the
530
+ # parenthesis closing `t(".created")` in a redirect's notice.
531
+ private_class_method def self.render_and_redirect_calls(code)
532
+ redirects = []
533
+ renders = []
534
+ tree = RailsAiContext::AstCache.parse_string(code).value
535
+ action = tree.statements.body.first.then { |node| node.name if node.is_a?(Prism::DefNode) }
536
+ visit = lambda do |node|
537
+ if node.is_a?(Prism::CallNode) && node.receiver.nil? && node.arguments
538
+ case node.name
539
+ when :redirect_to then redirects << redirect_text(node.arguments.arguments)
540
+ when :render then renders << "render #{node.arguments.slice.squish}"
541
+ end
542
+ elsif action && implicit_format_render?(node)
543
+ # `format.turbo_stream` with no block renders the action's own
544
+ # template for that format, a render no `render` call names.
545
+ renders << "render :#{action}, formats: :#{node.name} (implicit, from `format.#{node.name}`)"
546
+ end
547
+ node.compact_child_nodes.each(&visit)
548
+ end
549
+ visit.call(tree)
550
+ [ redirects, renders ]
551
+ end
552
+
553
+ RESPONSE_FORMATS = %i[html json xml js turbo_stream csv text].freeze
554
+
555
+ private_class_method def self.implicit_format_render?(node)
556
+ node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::LocalVariableReadNode) &&
557
+ RESPONSE_FORMATS.include?(node.name) && node.arguments.nil? && node.block.nil?
558
+ end
559
+
560
+ # `redirect_to target (notice: ..., alert: ...)`: the flash it sets, and
561
+ # none of the status or other options beside it.
562
+ private_class_method def self.redirect_text(arguments)
563
+ options, target = arguments.partition { |arg| arg.is_a?(Prism::KeywordHashNode) }
564
+ flash = options.flat_map(&:elements).filter_map do |pair|
565
+ key = pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode) ? pair.key.unescaped : nil
566
+ "#{key}: #{pair.value.slice.squish}" if %w[notice alert].include?(key)
567
+ end
568
+ "redirect_to #{target.map { |arg| arg.slice.squish }.join(", ")}#{" (#{flash.join(', ')})" if flash.any?}"
569
+ end
570
+
571
+ # Source as it is shown: every secret in it filtered, as search_code and
572
+ # get_edit_context show the same lines.
573
+ private_class_method def self.redacted(code, path)
574
+ RailsAiContext::Redaction.redact_source(code, path: path)
575
+ end
576
+
450
577
  private_class_method def self.extract_method_with_lines(file_path, method_name, source: nil, owner: nil)
451
578
  unless source
452
579
  return nil unless file_path && File.exist?(file_path)
@@ -461,9 +588,11 @@ module RailsAiContext
461
588
 
462
589
  private_class_method def self.format_controller(name, info, ctx)
463
590
  lines = [ "# #{name}", "" ]
591
+ lines << "**Alias of:** `#{info[:alias_of]}`, the same class under a second name" if info[:alias_of]
464
592
  lines << "**Parent:** `#{resolved_parent(name, info, ctx)}`" if info[:parent_class]
465
593
  lines << "**API controller:** yes" if info[:api_controller]
466
- lines << "**Formats:** #{info[:respond_to_formats].join(', ')}" if info[:respond_to_formats]&.any?
594
+ formats = formats_phrase(info, ctx, name)
595
+ lines << "**Formats:** #{formats}" if formats
467
596
  declared = RailsAiContext::Introspectors::ControllerSettings.resolve(ctx, name, root: rails_app.root.to_s)
468
597
  lines << "**Layout:** #{RailsAiContext::Introspectors::ControllerSettings.layout_phrase(declared[:layout])}" if declared[:layout]
469
598
 
@@ -493,8 +622,14 @@ module RailsAiContext
493
622
  permits_summary = (Array(sp[:permits]).map { |p| ":#{p}" } +
494
623
  Array(sp[:nested]).map { |key, fields| permit_field_text({ key => fields }) } +
495
624
  Array(sp[:arrays]).map { |key| "#{key}: []" } +
496
- Array(sp[:hashes]).map { |key| "#{key}: {}" }).join(", ")
497
- lines << "- `#{sp[:name]}`#{sp[:requires] ? " (requires: :#{sp[:requires]})" : ""}#{permits_summary.empty? ? "" : " permits: #{permits_summary}"}"
625
+ Array(sp[:hashes]).map { |key| "#{key}: {}" } +
626
+ Array(sp[:computed]).map { |key, source| "#{key}: #{source}" }).join(", ")
627
+ shape = if sp[:unrestricted]
628
+ " #{UNRESTRICTED}"
629
+ else
630
+ "#{sp[:requires] ? " (requires: :#{sp[:requires]})" : ""}#{permits_summary.empty? ? "" : " permits: #{permits_summary}"}"
631
+ end
632
+ lines << "- `#{sp[:name]}`#{params_origin(sp)}#{shape}"
498
633
  else
499
634
  lines << "- `#{sp}`"
500
635
  end
@@ -94,8 +94,8 @@ module RailsAiContext
94
94
  fingerprint_parts = []
95
95
  fingerprint_parts << conventions[:architecture].map { |a| humanize_arch(a) }.join(", ") if conventions[:architecture]&.any?
96
96
  fingerprint_parts << conventions[:patterns].map { |p| humanize_pattern(p) }.join(", ") if conventions[:patterns]&.any?
97
- top_dirs = conventions[:directory_structure]&.sort_by { |_, v| -v }&.first(5)&.map { |k, _| k }
98
- fingerprint_parts << "key dirs: #{top_dirs.join(', ')}" if top_dirs&.any?
97
+ top_dirs = (conventions[:directory_structure] || {}).sort_by.with_index { |(_, v), i| [ -v, i ] }.first(5).map { |k, _| k }
98
+ fingerprint_parts << "key dirs: #{top_dirs.join(', ')}" if top_dirs.any?
99
99
  if fingerprint_parts.any?
100
100
  lines << "" << "## Convention Fingerprint"
101
101
  lines << "This app uses #{fingerprint_parts.join('; ')}."
@@ -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