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
@@ -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
@@ -229,7 +233,7 @@ module RailsAiContext
229
233
  }
230
234
  end
231
235
 
232
- sorted = helpers_data.sort_by { |h| -h[:method_count] }
236
+ sorted = helpers_data.sort_by.with_index { |h, i| [ -h[:method_count], i ] }
233
237
  page = paginate(sorted, offset: offset, limit: limit, default_limit: 50)
234
238
  declared = controller_helper_methods(root)
235
239
 
@@ -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
 
@@ -417,7 +417,7 @@ module RailsAiContext
417
417
  # Queue summary
418
418
  # An unresolved base has no queue the scan can claim, so it is counted apart.
419
419
  queues = job_data.map { |j| j[:queue] || (j[:unknown_base] ? "unknown" : "default") }
420
- .tally.sort_by { |_, c| -c }
420
+ .tally.sort_by.with_index { |(_, c), i| [ -c, i ] }
421
421
  if queues.any?
422
422
  queue_str = queues.map { |q, c| "#{q}(#{c})" }.join(", ")
423
423
  lines << "**Queues:** #{queue_str}"
@@ -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?
@@ -44,7 +44,7 @@ module RailsAiContext
44
44
  data = models[key]
45
45
  unless data
46
46
  reason = RailsAiContext::AppKind.without_active_record(rails_app.root)
47
- return empty_response("No Active Record model named #{model}: #{reason}.") if reason && models.empty?
47
+ return empty_response("No Active Record model named #{echo_input(model)}: #{reason}.") if reason && models.empty?
48
48
 
49
49
  return not_found_response("Model", model, models.keys.sort,
50
50
  recovery_tool: "Call rails_get_model_details(detail:\"summary\") to see all models")
@@ -96,8 +96,9 @@ module RailsAiContext
96
96
  next
97
97
  end
98
98
  assocs = Serializers::SectionFacts.associations_list(data).join(", ")
99
+ note = Serializers::SectionFacts.table_note(Payload.section(cached_context, :schema), data)
99
100
  line = "- **#{name}**"
100
- line += " (table: #{data[:table_name]})" if data[:table_name]
101
+ line += note ? " (table #{note})" : (" (table: #{data[:table_name]})" if data[:table_name]).to_s
101
102
  line += " - #{assocs}" unless assocs.empty?
102
103
  lines << line
103
104
  end
@@ -155,15 +156,20 @@ module RailsAiContext
155
156
  private_class_method def self.format_model(name, data)
156
157
  # Static-tier entries already carry [STATIC]; a runtime entry with a
157
158
  # resolved table is reflection-confirmed, hence [VERIFIED].
159
+ note = Serializers::SectionFacts.table_note(Payload.section(cached_context, :schema), data)
158
160
  header_tag = if data[:confidence]
159
161
  " #{data[:confidence]}"
160
- elsif data[:table_name] && !RailsAiContext.static_tier?
162
+ elsif data[:table_name] && !note && !RailsAiContext.static_tier?
161
163
  " #{Confidence::VERIFIED}"
162
164
  else
163
165
  ""
164
166
  end
165
167
  lines = [ "# #{name}#{header_tag}", "" ]
166
- lines << "**Table:** `#{data[:table_name]}`" if data[:table_name]
168
+ if note
169
+ lines << "**Table:** #{note}"
170
+ elsif data[:table_name]
171
+ lines << "**Table:** `#{data[:table_name]}`"
172
+ end
167
173
  if (database = data[:database])
168
174
  lines << "**Database:** `#{database[:connects_to]}`#{" (only #{database[:condition]})" if database[:condition]}" \
169
175
  "#{", inherited from `#{database[:declared_in]}`" if database[:declared_in]}"
@@ -174,6 +180,10 @@ module RailsAiContext
174
180
  # all, so this stands outside that section.
175
181
  bases_unread = data[:bases_unread]
176
182
  lines << unread_bases_line(bases_unread) if bases_unread&.any?
183
+ if (patches = data[:patches_unread])&.any?
184
+ lines << "#{Confidence::UNAVAILABLE} #{count_phrase(patches.size, "patch module")} not read, so what " \
185
+ "#{patches.one? ? "it adds" : "they add"} to #{name} is missing here: #{patches.join(', ')}"
186
+ end
177
187
 
178
188
  # File structure - compact one-line format
179
189
  structure = extract_model_structure(name)
@@ -220,11 +230,16 @@ module RailsAiContext
220
230
  # Mongoid documents have no AR table/columns - render declared fields
221
231
  # and embedded relations directly from the source-parsed macros instead.
222
232
  if data[:mongoid]
233
+ # The collection is where the documents live, as the table is for a
234
+ # record; an embedded document has none of its own.
235
+ lines.insert(2, "**Collection:** `#{data[:collection]}`") if data[:collection]
236
+ lines.insert(2, "**Embedded in:** `#{data[:embedded_in]}` (no collection of its own)") if data[:embedded_in]
223
237
  if data[:fields]&.any?
224
238
  lines << "" << "## Fields"
225
239
  data[:fields].each do |f|
226
240
  type_str = f[:type] ? ": #{f[:type]}" : ""
227
- lines << "- `#{f[:name]}`#{type_str}#{", default: #{f[:default]}" if f.key?(:default)}"
241
+ from = f[:implicit] ? " _(#{f[:implicit]})_" : ""
242
+ lines << "- `#{f[:name]}`#{type_str}#{", default: #{f[:default]}" if f.key?(:default)}#{from}"
228
243
  end
229
244
  end
230
245
 
@@ -282,7 +297,7 @@ module RailsAiContext
282
297
  data[:validations].each do |v|
283
298
  next if v[:implicit] && (v[:attributes] & written).any?
284
299
 
285
- dedup_key = [ v[:kind].to_s, v[:attributes].sort, v[:options] ]
300
+ dedup_key = [ v[:kind].to_s, v[:validator], v[:attributes].sort, v[:options] ]
286
301
  next if seen_validations.include?(dedup_key)
287
302
  seen_validations << dedup_key
288
303
  attrs = v[:attributes].join(", ")
@@ -563,8 +578,10 @@ module RailsAiContext
563
578
  # Cross-reference hints - guide AI to related tools
564
579
  hints = []
565
580
  hints << "`rails_get_schema(table:\"#{data[:table_name]}\")` for columns/indexes" if data[:table_name]
566
- controller_name = "#{name.pluralize}Controller"
567
- hints << "`rails_get_controllers(controller:\"#{controller_name}\")` for actions" if name.match?(/\A[A-Z][a-z]/)
581
+ # Only a controller the app has: the conventional name is a guess, and
582
+ # a hint naming a class that does not exist sends the reader nowhere.
583
+ controller_name = Payload.controller_for_route_key(cached_context, name.underscore.pluralize)&.first
584
+ hints << "`rails_get_controllers(controller:\"#{controller_name}\")` for actions" if controller_name
568
585
  hints << "`rails_analyze_feature(feature:\"#{name}\")` for full-stack view"
569
586
  lines << "" << "_Next: #{hints.join(' | ')}_"
570
587
 
@@ -579,9 +596,12 @@ module RailsAiContext
579
596
  end
580
597
 
581
598
  # A carried path can name a gem rather than the app, and joining that to
582
- # the app root opens nothing. One reader answers both shapes.
599
+ # the app root opens nothing. One reader answers both shapes. A file a
600
+ # symlink carries out of the app is not read.
583
601
  private_class_method def self.resolved_model_path(model_name)
584
- RailsAiContext::PortablePath.resolve(relative_model_path(model_name), rails_app.root.to_s)
602
+ root = rails_app.root.to_s
603
+ path = RailsAiContext::PortablePath.resolve(relative_model_path(model_name), root)
604
+ path unless path && RailsAiContext::PathResolver.linked_out?(path, root)
585
605
  end
586
606
 
587
607
  # The macro's options are nested under an :options key. Printing that
@@ -46,8 +46,8 @@ module RailsAiContext
46
46
  # decides whether the partial exists.
47
47
  guard = RailsAiContext::SafePath.locate(partial, under: views_dir, root: root)
48
48
  case guard.refusal
49
- when :traversal then return error_response("Path not allowed: #{partial}")
50
- when :sensitive then return error_response("Path not allowed: #{partial} (sensitive file)")
49
+ when :traversal then return error_response("Path not allowed: #{echo_input(partial)}")
50
+ when :sensitive then return error_response("Path not allowed: #{echo_input(partial)} (sensitive file)")
51
51
  end
52
52
 
53
53
  if view_dirs.empty?
@@ -70,6 +70,12 @@ module RailsAiContext
70
70
 
71
71
  located = view_dirs.lazy.filter_map { |dir| resolve_partial_path(dir, partial) }.first
72
72
 
73
+ # The file the name resolves to, refused on policy: a link out of the app.
74
+ case located&.refusal
75
+ when :traversal, :outside then return error_response("Path not allowed: #{echo_input(partial)}")
76
+ when :sensitive then return error_response("Path not allowed: #{echo_input(partial)} (sensitive file)")
77
+ end
78
+
73
79
  unless located
74
80
  available = view_dirs.flat_map { |dir| find_available_partials(dir, root) }.uniq.sort.first(30)
75
81
  return not_found_response("Partial", partial, available,
@@ -92,14 +98,24 @@ module RailsAiContext
92
98
  # The resolved file, not the caller's spelling: a bare name has no
93
99
  # directory for a render site to be matched against.
94
100
  prefixed = prefix_partial_paths?(root)
95
- render_sites = view_dirs.flat_map { |dir| find_render_sites(dir, partial_name, root, prefixed) }
101
+ render_sites = view_dirs.flat_map { |dir| find_render_sites(dir, partial_name, root, prefixed) } +
102
+ code_render_sites(root, canonical_name(partial_name))
103
+ # `_post.html.erb` and `_post.json.jbuilder` share a name but not their
104
+ # callers: each renders only where its format is the one looked up.
105
+ partial_format = template_format(file_path)
106
+ render_sites = render_sites.select { |site| renders_format?(site, partial_format) }
96
107
  method_calls = {}
97
108
 
98
109
  # Primary: locals from render call sites (ground truth)
99
110
  render_locals = render_sites.flat_map { |rs| rs[:locals] || [] }.uniq
100
111
 
101
112
  # Secondary: local_assigns checks + defined? guards in partial source
102
- source_locals = extract_local_variable_references(source, Introspectors::HelperNames.for(root))
113
+ helpers = Introspectors::HelperNames.for(root)
114
+ source_locals = if file_path.end_with?(".jbuilder")
115
+ jbuilder_locals(source, helpers)
116
+ else
117
+ extract_local_variable_references(source, helpers)
118
+ end
103
119
 
104
120
  # Combine: render-site locals first, then source-detected locals
105
121
  # Filter out noise: single chars, capitalized words, known helpers
@@ -170,7 +186,7 @@ module RailsAiContext
170
186
  next unless full && site[:snippet]
171
187
 
172
188
  lines << " ```erb"
173
- lines << " #{site[:snippet].strip}"
189
+ lines << " #{RailsAiContext::Redaction.redact_source(site[:snippet].strip, path: site[:file])}"
174
190
  lines << " ```"
175
191
  end
176
192
  if render_sites.size > cap
@@ -183,7 +199,7 @@ module RailsAiContext
183
199
  if full
184
200
  lines << "" << "## Source"
185
201
  lines << "```erb"
186
- lines << source
202
+ lines << RailsAiContext::Redaction.redact_source(source, path: relative_path)
187
203
  lines << "```"
188
204
  else
189
205
  lines << ""
@@ -223,8 +239,9 @@ module RailsAiContext
223
239
 
224
240
  return nil unless found
225
241
 
242
+ # A refused file comes back with its refusal, so the caller says so rather than call it missing.
226
243
  located = RailsAiContext::SafePath.locate(found.delete_prefix(views_dir + File::SEPARATOR), under: views_dir, root: rails_app.root.to_s)
227
- located.ok? || located.refusal == :too_large ? located : nil
244
+ located.refusal == :missing ? nil : located
228
245
  end
229
246
 
230
247
  # A view file's name as the app renders it: its path under the innermost
@@ -309,10 +326,14 @@ module RailsAiContext
309
326
  code = match[0].strip
310
327
  next if code.start_with?("#")
311
328
 
312
- # 1. Standalone ERB output: <%= local_name %> or <%= local_name.method %>
313
- if (m = code.match(/\A\s*([a-z_]\w*)\s*(?:\z|\.|\()/))
329
+ # 1. Standalone ERB output: <%= local_name %> or <%= local_name.method %>.
330
+ # A name called with parentheses is a method, and one ending in
331
+ # _path or _url a route helper: `dom_id(notification)` and
332
+ # `mark_read_notification_path(notification)` were listed as locals.
333
+ if (m = code.match(/\A\s*([a-z_]\w*)\s*(?:\z|\.)/))
314
334
  name = m[1]
315
- locals << name unless known_non_locals.include?(name) || block_params.include?(name) || helpers.include?(name)
335
+ locals << name unless known_non_locals.include?(name) || block_params.include?(name) ||
336
+ helpers.include?(name) || route_helper?(name)
316
337
  end
317
338
 
318
339
  # 2. defined?(local) guard pattern
@@ -334,6 +355,200 @@ module RailsAiContext
334
355
  RailsAiContext.debug_fail(e, [], label: "extract_local_variable_references")
335
356
  end
336
357
 
358
+ # Every template a render site can sit in, jbuilder's Ruby included.
359
+ SITE_GLOB = "**/*.{erb,haml,slim,jbuilder}"
360
+
361
+ # Whether a site looks a partial of this format up: in the formats the
362
+ # call names, else those its place gives it (nil for any).
363
+ private_class_method def self.renders_format?(site, format)
364
+ return true unless format
365
+
366
+ formats = site.key?(:formats) ? site[:formats] : template_lookup(site[:file])
367
+ formats.nil? || formats.include?(format)
368
+ end
369
+
370
+ # A template looks in its own format: a json.jbuilder in json, an
371
+ # html.erb in html, and one whose name gives none in any. A
372
+ # turbo_stream template looks in html too (Turbo's request accepts
373
+ # html), as a js one does (Rails' html fallback for js).
374
+ private_class_method def self.template_lookup(file)
375
+ own = template_format(file) or return nil
376
+ %w[turbo_stream js].include?(own) ? [ own, "html" ] : [ own ]
377
+ end
378
+
379
+ # `render partial: "posts/post", formats: [:json]`: the formats a call
380
+ # names, which Rails looks the partial up in whatever the request asked.
381
+ # Action View reads `options[:formats]` alone, from the hash it renders
382
+ # with: the one holding `partial:`, or json.partial!'s keywords. So a
383
+ # `format:` is a local, as is any key inside `locals: {}`, and in
384
+ # `render "shared/date", date: d, format: :short` every key is one.
385
+ FORMATS_OPTION = /(?<![\w:])(?:formats:|:formats\s*=>)\s*(?:%[iIwW])?(\[[^\]]*\]|:\w+|["']\w+["'])/
386
+
387
+ # The formats the options beside offset `at` name: those of the
388
+ # innermost bracket group holding it, or of the whole call, read with
389
+ # each hash and call nested there blanked. nil for no offset, which a
390
+ # call that names its partial first has.
391
+ private_class_method def self.named_formats(call, at)
392
+ return nil unless at
393
+
394
+ level = +""
395
+ RailsAiContext::Brackets.each_top_level(call, comments: :ruby) do |piece, kind|
396
+ from = level.length
397
+ return named_formats(piece[1...-1], at - from - 1) if kind == :group && at > from && at < from + piece.length - 1
398
+
399
+ nested = kind == :comment || (kind == :group && !piece.start_with?("["))
400
+ level << (nested ? " " * piece.length : piece)
401
+ end
402
+ level[FORMATS_OPTION, 1]&.scan(/\w+/)
403
+ end
404
+
405
+ # Where a render names the partial as its `partial:` option, or nil
406
+ # where it names it first, as the shorthand does.
407
+ private_class_method def self.partial_key_at(line, name)
408
+ line =~ /(?:\bpartial:|:partial\s*=>)\s*(["'])#{Regexp.escape(name)}\1/
409
+ end
410
+
411
+ # The formats a call in Ruby renders its partial in when it names none.
412
+ # A Turbo Stream builder renders html, and so do a broadcast and
413
+ # ApplicationController.render outside a request. In a controller or a
414
+ # helper the request decides, html or json alike, unless the call sits
415
+ # in a `format.json { }` block of a respond_to.
416
+ private_class_method def self.code_site_formats(call, relative, line, format_blocks)
417
+ return [ "html" ] if call.match?(/\bturbo_stream\.\w+/)
418
+
419
+ block = format_blocks.select { |from, to, _| from <= line && line <= to }.max_by(&:first)
420
+ return (block.last == "any" ? nil : [ block.last ]) if block
421
+ return nil if relative.match?(%r{(?:\A|/)app/(?:controllers|helpers)/})
422
+
423
+ [ "html" ]
424
+ end
425
+
426
+ # [first line, last line, format] of each `format.json { }` block a
427
+ # respond_to holds, whatever its block names the collector.
428
+ private_class_method def self.format_blocks(content)
429
+ blocks = []
430
+ visit = lambda do |node|
431
+ if node.is_a?(Prism::CallNode) && node.name == :respond_to && node.block.is_a?(Prism::BlockNode)
432
+ param = node.block.parameters&.parameters&.requireds&.first
433
+ collector = param.respond_to?(:name) ? param.name : nil
434
+ collect = lambda do |inner|
435
+ if inner.is_a?(Prism::CallNode) && inner.block && inner.receiver.is_a?(Prism::LocalVariableReadNode) && inner.receiver.name == collector
436
+ blocks << [ inner.block.location.start_line, inner.block.location.end_line, inner.name.to_s ]
437
+ end
438
+ inner.compact_child_nodes.each(&collect)
439
+ end
440
+ collect.call(node.block)
441
+ end
442
+ node.compact_child_nodes.each(&visit)
443
+ end
444
+ visit.call(RailsAiContext::AstCache.parse_string(content).value)
445
+ blocks
446
+ rescue => e
447
+ RailsAiContext.debug_fail(e, [], label: "format_blocks")
448
+ end
449
+
450
+ # A template's format as its name gives it; jbuilder's handler writes
451
+ # json, its default format, when the name gives none.
452
+ private_class_method def self.template_format(path)
453
+ RailsAiContext::ViewFile.format_of(path) || ("json" if path.end_with?(".jbuilder"))
454
+ end
455
+
456
+ # "shared/_status_badge.html.erb" as a render call names it: "shared/status_badge".
457
+ private_class_method def self.canonical_name(partial_name)
458
+ parts = partial_name.split("/")
459
+ (parts[0...-1] + [ parts.last.delete_prefix("_").sub(/\..*\z/, "") ]).join("/")
460
+ end
461
+
462
+ private_class_method def self.route_helper?(name)
463
+ name.match?(/_(?:path|url)\z/)
464
+ end
465
+
466
+ # Names Ruby cannot tell from a method call at parse time that a jbuilder
467
+ # template reads without defining them itself.
468
+ JBUILDER_NON_LOCALS = %w[json local_assigns params request controller current_page? render cache!].to_set.freeze
469
+
470
+ # A jbuilder partial is Ruby throughout, so the ERB tag scan found none
471
+ # of its locals: `invoice` in api/v1/invoices/_invoice.json.jbuilder.
472
+ # A local reads as a bare name the file never assigns, which Prism marks
473
+ # as a variable call; a helper or a route helper is not one.
474
+ private_class_method def self.jbuilder_locals(source, helpers)
475
+ names = Set.new
476
+ visit = lambda do |node|
477
+ names << node.name.to_s if node.is_a?(Prism::CallNode) && node.variable_call?
478
+ node.compact_child_nodes.each(&visit)
479
+ end
480
+ visit.call(RailsAiContext::AstCache.parse_string(source).value)
481
+ names.reject { |name| JBUILDER_NON_LOCALS.include?(name) || helpers.include?(name) || route_helper?(name) }.sort
482
+ rescue => e
483
+ RailsAiContext.debug_fail(e, [], label: "jbuilder_locals")
484
+ end
485
+
486
+ # Where a partial is named outside a render call: turbo_stream.prepend
487
+ # in a .turbo_stream.erb, broadcast_prepend_to in a model, json.partial!
488
+ # and json.array! in jbuilder. Ruby code outside the views names a
489
+ # partial by its full path, since it has no view directory of its own.
490
+ CODE_DIRS = %w[app/models app/controllers app/jobs app/channels app/helpers].freeze
491
+ # The keywords Turbo and jbuilder take beside a partial, which are not its locals.
492
+ PARTIAL_CALL_KEYWORDS = %w[target targets action attributes method html content renderable request_id as].freeze
493
+
494
+ private_class_method def self.partial_arg_sites(content, relative, names, skip_spans: [], code: false)
495
+ lines = content.lines
496
+ sites = []
497
+ blocks = nil
498
+ content.to_enum(:scan, Introspectors::ViewTemplateIntrospector::PARTIAL_ARG).each do
499
+ match = Regexp.last_match
500
+ at = match.begin(0)
501
+ next unless names.include?(match[2])
502
+ next if skip_spans.any? { |from, to| at >= from && at < to }
503
+
504
+ # The line the call starts on, which a trailing comma carries onto this one.
505
+ start = content[0...at].count("\n")
506
+ start -= 1 while start.positive? && lines[start - 1].rstrip.end_with?(",")
507
+ call = call_lines(lines, start)
508
+ site = { file: relative, line: start + 1, locals: partial_call_locals(call), snippet: call.squish }
509
+ # The name's own offset in the call: the options it is rendered
510
+ # with sit beside it, inside json.partial!'s parentheses too.
511
+ named = named_formats(call, match.begin(1) - lines.first(start).sum(&:length))
512
+ if code
513
+ site[:formats] = named || code_site_formats(call, relative, start + 1, blocks ||= format_blocks(content))
514
+ elsif named
515
+ site[:formats] = named
516
+ end
517
+ sites << site
518
+ end
519
+ sites
520
+ end
521
+
522
+ # The line a call starts on and every line a trailing comma carries it onto.
523
+ private_class_method def self.call_lines(lines, index)
524
+ taken = []
525
+ lines[index..].each do |line|
526
+ taken << line
527
+ break unless line.split("%>", 2).first.rstrip.end_with?(",")
528
+ end
529
+ taken.join.split("%>", 2).first
530
+ end
531
+
532
+ # The locals a call hands the partial: its `locals:` hash, the name
533
+ # `as:` gives each record, or json.partial!'s own keywords.
534
+ private_class_method def self.partial_call_locals(call)
535
+ named = call[/\bas:\s*:(\w+)/, 1]
536
+ (extract_locals_from_render(call) - PARTIAL_CALL_KEYWORDS + [ named ]).compact.uniq
537
+ end
538
+
539
+ private_class_method def self.code_render_sites(root, canonical)
540
+ CODE_DIRS.flat_map { |kind| RailsAiContext::PathResolver.dirs_for(root, kind) }.uniq.flat_map do |dir|
541
+ RailsAiContext::FileWalk.glob(dir, "**/*.rb", root: root).flat_map do |path|
542
+ content = safe_read(path)
543
+ next [] unless content&.include?(canonical)
544
+
545
+ partial_arg_sites(content, RailsAiContext::PortablePath.relativize(path, root), [ canonical ], code: true)
546
+ end
547
+ end
548
+ rescue => e
549
+ RailsAiContext.debug_fail(e, [], label: "code_render_sites")
550
+ end
551
+
337
552
  # Find all views that render this partial and extract the locals they pass.
338
553
  private_class_method def self.find_render_sites(views_dir, partial, root, prefixed)
339
554
  sites = []
@@ -359,7 +574,7 @@ module RailsAiContext
359
574
 
360
575
  object_paths = {}
361
576
 
362
- view_files = RailsAiContext::ViewFile.glob(root, views_dir, RailsAiContext::ViewFile::MARKUP_GLOB)
577
+ view_files = RailsAiContext::ViewFile.glob(root, views_dir, SITE_GLOB)
363
578
 
364
579
  view_files.each do |file|
365
580
  content = safe_read(file)
@@ -371,8 +586,10 @@ module RailsAiContext
371
586
  # Per call, not per line: `render(` and a call split over lines are
372
587
  # one call whose arguments the line alone does not hold.
373
588
  covered = 0
589
+ spans = []
374
590
  Introspectors::ViewTemplateIntrospector.render_calls(content).each do |at, args|
375
591
  args = args.split("%>", 2).first
592
+ spans << [ at, at + args.length ]
376
593
  # A render inside another's arguments is already part of that call's site.
377
594
  next if at < covered
378
595
 
@@ -384,25 +601,22 @@ module RailsAiContext
384
601
 
385
602
  matched_line = false
386
603
  search_patterns.each do |search_name|
387
- # Match render "partial_name" or render partial: "partial_name"
388
- # Allow content before search_name (e.g. "shared/status_badge" matches "status_badge")
389
- next unless line.match?(/render\s.*["'][^"']*#{Regexp.escape(search_name)}["']/)
604
+ # Match render "partial_name" or render partial: "partial_name",
605
+ # the name whole: "comments/form" is another directory's form.
606
+ next unless line.match?(/render\s.*["']#{Regexp.escape(search_name)}["']/)
390
607
 
391
- # For short basename matches, verify directory context
608
+ # A bare name reaches the partial only from its own directory.
392
609
  if search_name == basename && dir_prefix.length > 0
393
- # Only match if the full path is referenced, or the render is in the same directory
394
610
  file_dir = File.dirname(file).sub("#{views_dir}/", "")
395
- next unless line.include?(dir_prefix) || file_dir == dir_prefix
611
+ next unless file_dir == dir_prefix
396
612
  end
397
613
 
398
614
  locals_passed = extract_locals_from_render(line)
399
615
 
400
- sites << {
401
- file: relative,
402
- line: line_num,
403
- locals: locals_passed,
404
- snippet: snippet
405
- }
616
+ site = { file: relative, line: line_num, locals: locals_passed, snippet: snippet }
617
+ named = named_formats(line, partial_key_at(line, search_name))
618
+ site[:formats] = named if named
619
+ sites << site
406
620
  matched_line = true
407
621
  break # one match per call is enough
408
622
  end
@@ -417,6 +631,11 @@ module RailsAiContext
417
631
 
418
632
  sites << { file: relative, line: line_num, locals: [], snippet: snippet }
419
633
  end
634
+
635
+ # A bare name reaches the partial only from its own directory.
636
+ file_dir = File.dirname(file).sub("#{views_dir}/", "")
637
+ names = dir_prefix.empty? || file_dir == dir_prefix ? search_patterns : [ canonical ]
638
+ sites.concat(partial_arg_sites(content, relative, names, skip_spans: spans))
420
639
  end
421
640
 
422
641
  sites
@@ -25,7 +25,9 @@ module RailsAiContext
25
25
  },
26
26
  app_only: {
27
27
  type: "boolean",
28
- description: "Filter out internal Rails routes (Active Storage, Action Mailbox, Conductor, etc.). Default: true."
28
+ description: "Leave out the routes Rails' engines and gems draw on their own (Active Storage, Action Mailbox and its conductor, " \
29
+ "Turbo Native navigation, Rails' info and mailer-preview pages). Routes the app's route files declare stay, " \
30
+ "devise_for's and the health check's included. A controller filter searches every route. Default: true."
29
31
  }
30
32
  }
31
33
  )
@@ -41,16 +43,25 @@ module RailsAiContext
41
43
 
42
44
  # The filters this controller actually runs, read the way every other
43
45
  # surface reads them, so a skipped one is never named here. The cut is
44
- # stated rather than silent.
46
+ # stated rather than silent. A filter that runs on some actions only says
47
+ # which, so a route whose action skips it is not read as guarded by it.
45
48
  def self.filter_hint(ctrl_class, ctx)
46
49
  chain = RailsAiContext::ActionFilters.for_controller(ctx, ctrl_class, root: rails_app&.root&.to_s)
47
- names = chain[:chain].map { |f| f[:name] }
50
+ names = chain[:chain].map { |f| "#{f[:name]}#{filter_scope(f)}" }
48
51
  return nil if names.empty?
49
52
 
50
53
  rest = names.size - HINTED_FILTERS
51
54
  "#{names.first(HINTED_FILTERS).join(', ')}#{rest.positive? ? " (+#{rest} more)" : ''}"
52
55
  end
53
56
 
57
+ def self.filter_scope(filter)
58
+ scope = +""
59
+ scope << " (only: #{Array(filter[:only]).join(', ')})" if Array(filter[:only]).any?
60
+ scope << " (except: #{Array(filter[:except]).join(', ')})" if Array(filter[:except]).any?
61
+ scope + RailsAiContext::Serializers::SectionFacts.skip_condition_tail(filter)
62
+ end
63
+ private_class_method :filter_scope
64
+
54
65
  guide_row(
55
66
  order: 8,
56
67
  mcp: "rails_get_routes(controller:\"X\")",
@@ -110,16 +121,21 @@ module RailsAiContext
110
121
  elsif group[:mount_computed] then " at a path the routes compute"
111
122
  else " (not mounted by the app's routes)"
112
123
  end
113
- at += " (also #{group[:also_mounted_at].map { |path| "`#{path}`" }.join(', ')})" if group[:also_mounted_at]
124
+ at += " (also #{also_mounted_at(group)})" if group[:also_mounted_at]
114
125
  at
115
126
  end
116
127
 
128
+ private_class_method def self.also_mounted_at(group)
129
+ group[:also_mounted_at].map { |path| "`#{path}`" }.join(", ")
130
+ end
131
+
117
132
  # The routes a controller filter matches in the engines' tables, one
118
133
  # group per controller labelled with its engine, listed like the app's.
119
134
  private_class_method def self.engine_controller_groups(engine_routes, needles)
120
135
  Array(engine_routes).each_with_object({}) do |group, found|
121
136
  matches = Array(group[:routes]).select { |r| needles.any? { |n| path_segments_match?(r[:controller].to_s, n, tail: true) } }
122
137
  where = group[:mount] || group[:mount_computed] ? "" : ", not mounted by the app's routes"
138
+ where += ", also mounted at #{also_mounted_at(group)}" if group[:also_mounted_at]
123
139
  matches.group_by { |r| r[:controller] }.each do |ctrl, rows|
124
140
  found["#{ctrl} (in #{group[:engine]}'s table#{where})"] = rows
125
141
  end
@@ -143,8 +159,10 @@ module RailsAiContext
143
159
 
144
160
  # Filter out internal Rails routes by default, remembering how many
145
161
  # were dropped so headers can say the count is app-only, not the total.
162
+ # A controller filter names the routes it wants, so it searches the
163
+ # whole table: `controller:"active_storage/blobs"` is asked for by name.
146
164
  excluded_framework_count = 0
147
- if app_only
165
+ if app_only && controller.nil?
148
166
  framework_ctrls = by_controller.select { |k, _| framework_controller?(k) }
149
167
  excluded_framework_count = framework_ctrls.values.sum { |actions| RailsAiContext::RouteCoverage.dedupe_put_patch_routes(actions).size }
150
168
  by_controller = by_controller.reject { |k, _| framework_controller?(k) }
@@ -164,9 +182,12 @@ module RailsAiContext
164
182
  filtered = filtered.merge(engine_controller_groups(routes[:engine_routes], needles))
165
183
  if filtered.empty?
166
184
  # The whole controller list would bury the sentence saying the name matched none.
167
- known = by_controller.keys.sort
168
- return empty_response("No routes for '#{controller}'. Controllers: " \
169
- "#{known.first(20).join(', ')}#{" ... and #{known.size - 20} more" if known.size > 20}")
185
+ known = by_controller.keys.reject { |k| framework_controller?(k) }.sort
186
+ # Booted, a dynamic route has no controller, so it cannot be the one asked for.
187
+ unexpanded = routes[:confidence] == RailsAiContext::Confidence::STATIC ? routes[:dynamic_routes].to_i : 0
188
+ unread = ". #{count_phrase(unexpanded, "dynamic construct")} not expanded, so a route for this controller may be missing from this table." if unexpanded.positive?
189
+ return empty_response("No routes for '#{echo_input(controller)}'. Controllers: " \
190
+ "#{known.first(20).join(', ')}#{" ... and #{known.size - 20} more" if known.size > 20}#{unread}")
170
191
  end
171
192
  by_controller = filtered
172
193
  end