rails-ai-context 5.30.2 → 5.32.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 (222) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +868 -0
  3. data/CONTEXT.md +7 -3
  4. data/README.md +1 -1
  5. data/docs/ARCHITECTURE.md +3 -3
  6. data/docs/CLI.md +1 -1
  7. data/docs/COMPATIBILITY.md +50 -14
  8. data/docs/CONFIGURATION.md +4 -1
  9. data/docs/FAQ.md +1 -1
  10. data/docs/GUIDE.md +14 -8
  11. data/docs/INTROSPECTORS.md +50 -23
  12. data/docs/SECURITY.md +56 -1
  13. data/docs/TOOLS.md +58 -12
  14. data/lib/generators/rails_ai_context/install/install_generator.rb +1 -0
  15. data/lib/rails_ai_context/action_filters.rb +121 -48
  16. data/lib/rails_ai_context/app_commands.rb +2 -2
  17. data/lib/rails_ai_context/app_kind.rb +65 -8
  18. data/lib/rails_ai_context/ast_cache.rb +53 -18
  19. data/lib/rails_ai_context/base_mixins.rb +46 -16
  20. data/lib/rails_ai_context/boot_manager.rb +5 -1
  21. data/lib/rails_ai_context/cli/entry_boot.rb +17 -2
  22. data/lib/rails_ai_context/cli/tool_runner.rb +15 -1
  23. data/lib/rails_ai_context/concern_macros.rb +791 -111
  24. data/lib/rails_ai_context/concern_membership.rb +25 -5
  25. data/lib/rails_ai_context/concern_paths.rb +63 -17
  26. data/lib/rails_ai_context/config_yaml.rb +40 -0
  27. data/lib/rails_ai_context/configuration.rb +3 -3
  28. data/lib/rails_ai_context/data/docs/index.json +324 -0
  29. data/lib/rails_ai_context/database_yml.rb +136 -0
  30. data/lib/rails_ai_context/doctor.rb +30 -37
  31. data/lib/rails_ai_context/erb_source.rb +40 -0
  32. data/lib/rails_ai_context/fingerprinter.rb +48 -22
  33. data/lib/rails_ai_context/fixture_keys.rb +42 -1
  34. data/lib/rails_ai_context/gem_lock.rb +275 -47
  35. data/lib/rails_ai_context/git_ignore.rb +13 -11
  36. data/lib/rails_ai_context/hydrators/hydration_formatter.rb +8 -2
  37. data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +10 -5
  38. data/lib/rails_ai_context/introspector.rb +10 -1
  39. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +27 -19
  40. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -8
  41. data/lib/rails_ai_context/introspectors/action_resolver.rb +38 -26
  42. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -2
  43. data/lib/rails_ai_context/introspectors/active_record_settings.rb +56 -0
  44. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -1
  45. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +98 -22
  46. data/lib/rails_ai_context/introspectors/admin_resources.rb +101 -0
  47. data/lib/rails_ai_context/introspectors/anyway_configs.rb +64 -0
  48. data/lib/rails_ai_context/introspectors/apartment_config.rb +47 -0
  49. data/lib/rails_ai_context/introspectors/api_introspector.rb +125 -26
  50. data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +24 -19
  51. data/lib/rails_ai_context/introspectors/ast_walk.rb +18 -0
  52. data/lib/rails_ai_context/introspectors/auth_introspector.rb +71 -18
  53. data/lib/rails_ai_context/introspectors/autoload_introspector.rb +62 -15
  54. data/lib/rails_ai_context/introspectors/base.rb +38 -0
  55. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +349 -39
  56. data/lib/rails_ai_context/introspectors/component_introspector.rb +26 -14
  57. data/lib/rails_ai_context/introspectors/config_introspector.rb +22 -9
  58. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +1 -4
  59. data/lib/rails_ai_context/introspectors/controller_filters.rb +335 -31
  60. data/lib/rails_ai_context/introspectors/controller_introspector.rb +315 -129
  61. data/lib/rails_ai_context/introspectors/controller_settings.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/convention_introspector.rb +18 -15
  63. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/declared_constant.rb +108 -23
  65. data/lib/rails_ai_context/introspectors/eager_load.rb +12 -7
  66. data/lib/rails_ai_context/introspectors/engine_introspector.rb +13 -5
  67. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +62 -17
  68. data/lib/rails_ai_context/introspectors/env_introspector.rb +8 -6
  69. data/lib/rails_ai_context/introspectors/env_references.rb +21 -4
  70. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +37 -22
  71. data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -1
  72. data/lib/rails_ai_context/introspectors/gemfile_gems.rb +53 -5
  73. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +109 -0
  74. data/lib/rails_ai_context/introspectors/helper_names.rb +12 -1
  75. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +101 -46
  76. data/lib/rails_ai_context/introspectors/includers.rb +6 -1
  77. data/lib/rails_ai_context/introspectors/initializer_introspector.rb +2 -5
  78. data/lib/rails_ai_context/introspectors/job_introspector.rb +548 -103
  79. data/lib/rails_ai_context/introspectors/listener_registration.rb +11 -1
  80. data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +51 -4
  81. data/lib/rails_ai_context/introspectors/listeners/autoload_ignore_listener.rb +31 -0
  82. data/lib/rails_ai_context/introspectors/listeners/autoload_paths_listener.rb +4 -47
  83. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +80 -14
  84. data/lib/rails_ai_context/introspectors/listeners/branch_conditions.rb +156 -0
  85. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +85 -23
  86. data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +35 -6
  87. data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +30 -2
  88. data/lib/rails_ai_context/introspectors/listeners/conditional_macro_listener.rb +19 -0
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +255 -10
  90. data/lib/rails_ai_context/introspectors/listeners/constant_reference_listener.rb +34 -0
  91. data/lib/rails_ai_context/introspectors/listeners/constructor_macro_listener.rb +123 -0
  92. data/lib/rails_ai_context/introspectors/listeners/definition_file_paths_listener.rb +66 -0
  93. data/lib/rails_ai_context/introspectors/listeners/enums_listener.rb +2 -2
  94. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +31 -0
  95. data/lib/rails_ai_context/introspectors/listeners/filter_macro_listener.rb +54 -0
  96. data/lib/rails_ai_context/introspectors/listeners/fixture_paths_listener.rb +62 -0
  97. data/lib/rails_ai_context/introspectors/listeners/gemfile_dsl_listener.rb +42 -1
  98. data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +28 -2
  99. data/lib/rails_ai_context/introspectors/listeners/grape_api_listener.rb +137 -0
  100. data/lib/rails_ai_context/introspectors/listeners/http_client_call_listener.rb +50 -0
  101. data/lib/rails_ai_context/introspectors/listeners/i18n_load_path_listener.rb +54 -0
  102. data/lib/rails_ai_context/introspectors/listeners/literal_paths.rb +123 -0
  103. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +248 -4
  104. data/lib/rails_ai_context/introspectors/listeners/mailbox_routing_listener.rb +2 -1
  105. data/lib/rails_ai_context/introspectors/listeners/method_call_listener.rb +7 -1
  106. data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +352 -69
  107. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +1 -1
  108. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +35 -26
  109. data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +62 -29
  110. data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +8 -0
  111. data/lib/rails_ai_context/introspectors/listeners/namespaced_roots_listener.rb +35 -0
  112. data/lib/rails_ai_context/introspectors/listeners/nested_constants_listener.rb +32 -0
  113. data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
  114. data/lib/rails_ai_context/introspectors/listeners/preview_paths_listener.rb +16 -7
  115. data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
  116. data/lib/rails_ai_context/introspectors/listeners/queue_assignment_listener.rb +54 -0
  117. data/lib/rails_ai_context/introspectors/listeners/rake_task_dsl_listener.rb +21 -8
  118. data/lib/rails_ai_context/introspectors/listeners/route_files_listener.rb +4 -11
  119. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +461 -65
  120. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +76 -14
  121. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +40 -1
  122. data/lib/rails_ai_context/introspectors/listeners/validations_listener.rb +10 -1
  123. data/lib/rails_ai_context/introspectors/listeners/view_paths_listener.rb +37 -0
  124. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +47 -3
  125. data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -3
  126. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +4 -3
  127. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +72 -12
  128. data/lib/rails_ai_context/introspectors/migration_replay.rb +110 -14
  129. data/lib/rails_ai_context/introspectors/model_introspector.rb +708 -214
  130. data/lib/rails_ai_context/introspectors/module_aliases.rb +11 -3
  131. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +9 -57
  132. data/lib/rails_ai_context/introspectors/observability_introspector.rb +5 -8
  133. data/lib/rails_ai_context/introspectors/performance_introspector.rb +28 -8
  134. data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +118 -51
  135. data/lib/rails_ai_context/introspectors/recurring_schedules.rb +150 -0
  136. data/lib/rails_ai_context/introspectors/rendered_record.rb +87 -0
  137. data/lib/rails_ai_context/introspectors/retry_policy.rb +8 -5
  138. data/lib/rails_ai_context/introspectors/route_introspector.rb +153 -42
  139. data/lib/rails_ai_context/introspectors/schema_conventions.rb +127 -40
  140. data/lib/rails_ai_context/introspectors/schema_dump_path.rb +80 -0
  141. data/lib/rails_ai_context/introspectors/schema_introspector.rb +346 -112
  142. data/lib/rails_ai_context/introspectors/schema_reader.rb +69 -29
  143. data/lib/rails_ai_context/introspectors/service_classes.rb +21 -15
  144. data/lib/rails_ai_context/introspectors/source_calls.rb +1 -1
  145. data/lib/rails_ai_context/introspectors/source_introspector.rb +17 -2
  146. data/lib/rails_ai_context/introspectors/source_scan.rb +174 -26
  147. data/lib/rails_ai_context/introspectors/sqlite_virtual_tables.rb +28 -0
  148. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +16 -5
  149. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +372 -98
  150. data/lib/rails_ai_context/introspectors/superclass_chain.rb +14 -15
  151. data/lib/rails_ai_context/introspectors/table_name.rb +46 -45
  152. data/lib/rails_ai_context/introspectors/test_introspector.rb +289 -78
  153. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +10 -5
  154. data/lib/rails_ai_context/introspectors/view_introspector.rb +5 -40
  155. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +12 -10
  156. data/lib/rails_ai_context/method_name.rb +31 -0
  157. data/lib/rails_ai_context/migration_status.rb +5 -5
  158. data/lib/rails_ai_context/output_guard.rb +6 -4
  159. data/lib/rails_ai_context/package_json.rb +96 -5
  160. data/lib/rails_ai_context/path_resolver.rb +170 -15
  161. data/lib/rails_ai_context/payload.rb +48 -0
  162. data/lib/rails_ai_context/pending_migrations.rb +15 -18
  163. data/lib/rails_ai_context/portable_path.rb +76 -4
  164. data/lib/rails_ai_context/redaction.rb +8 -2
  165. data/lib/rails_ai_context/route_coverage.rb +5 -1
  166. data/lib/rails_ai_context/safe_file.rb +4 -1
  167. data/lib/rails_ai_context/safe_path.rb +61 -10
  168. data/lib/rails_ai_context/schema_hint.rb +8 -2
  169. data/lib/rails_ai_context/schema_version.rb +7 -9
  170. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
  171. data/lib/rails_ai_context/serializers/markdown_serializer.rb +13 -4
  172. data/lib/rails_ai_context/serializers/section_facts.rb +5 -1
  173. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +2 -2
  174. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -4
  175. data/lib/rails_ai_context/server.rb +26 -7
  176. data/lib/rails_ai_context/test_framework.rb +32 -5
  177. data/lib/rails_ai_context/tools/analyze_feature.rb +22 -10
  178. data/lib/rails_ai_context/tools/base_tool.rb +69 -30
  179. data/lib/rails_ai_context/tools/diagnose.rb +1 -1
  180. data/lib/rails_ai_context/tools/generate_test.rb +8 -9
  181. data/lib/rails_ai_context/tools/get_active_support.rb +19 -4
  182. data/lib/rails_ai_context/tools/get_api.rb +17 -11
  183. data/lib/rails_ai_context/tools/get_autoload.rb +10 -5
  184. data/lib/rails_ai_context/tools/get_callbacks.rb +65 -14
  185. data/lib/rails_ai_context/tools/get_component_catalog.rb +7 -4
  186. data/lib/rails_ai_context/tools/get_concern.rb +31 -18
  187. data/lib/rails_ai_context/tools/get_config.rb +52 -18
  188. data/lib/rails_ai_context/tools/get_controllers.rb +57 -13
  189. data/lib/rails_ai_context/tools/get_conventions.rb +44 -22
  190. data/lib/rails_ai_context/tools/get_engines.rb +2 -0
  191. data/lib/rails_ai_context/tools/get_env.rb +209 -52
  192. data/lib/rails_ai_context/tools/get_env_config.rb +38 -7
  193. data/lib/rails_ai_context/tools/get_frontend_stack.rb +56 -7
  194. data/lib/rails_ai_context/tools/get_helper_methods.rb +136 -26
  195. data/lib/rails_ai_context/tools/get_job_pattern.rb +143 -87
  196. data/lib/rails_ai_context/tools/get_mailers.rb +59 -2
  197. data/lib/rails_ai_context/tools/get_model_details.rb +178 -28
  198. data/lib/rails_ai_context/tools/get_partial_interface.rb +101 -62
  199. data/lib/rails_ai_context/tools/get_routes.rb +35 -9
  200. data/lib/rails_ai_context/tools/get_schema.rb +160 -61
  201. data/lib/rails_ai_context/tools/get_service_pattern.rb +121 -25
  202. data/lib/rails_ai_context/tools/get_stimulus.rb +1 -1
  203. data/lib/rails_ai_context/tools/get_test_info.rb +152 -82
  204. data/lib/rails_ai_context/tools/get_view.rb +37 -20
  205. data/lib/rails_ai_context/tools/migration_advisor.rb +64 -24
  206. data/lib/rails_ai_context/tools/onboard.rb +174 -88
  207. data/lib/rails_ai_context/tools/performance_check.rb +5 -13
  208. data/lib/rails_ai_context/tools/query.rb +122 -28
  209. data/lib/rails_ai_context/tools/read_logs.rb +45 -17
  210. data/lib/rails_ai_context/tools/runtime_info.rb +1 -1
  211. data/lib/rails_ai_context/tools/safe_call.rb +20 -15
  212. data/lib/rails_ai_context/tools/search_code.rb +114 -40
  213. data/lib/rails_ai_context/tools/search_docs.rb +22 -11
  214. data/lib/rails_ai_context/tools/security_scan.rb +51 -4
  215. data/lib/rails_ai_context/tools/validate.rb +21 -4
  216. data/lib/rails_ai_context/tools/validate_semantics.rb +18 -6
  217. data/lib/rails_ai_context/version.rb +1 -1
  218. data/lib/rails_ai_context/vfs.rb +2 -2
  219. data/lib/rails_ai_context/view_file.rb +53 -3
  220. data/lib/rails_ai_context.rb +10 -2
  221. data/server.json +1 -1
  222. metadata +34 -3
@@ -47,7 +47,7 @@ module RailsAiContext
47
47
  component = component.to_s.strip
48
48
 
49
49
  if components.empty?
50
- note = api_only_note("app/components")
50
+ note = api_only_note("app/components", dir: "app/components")
51
51
  return text_response(note) if note
52
52
 
53
53
  return text_response("Component '#{component}' not found - no components exist in app/components/. Create ViewComponent or Phlex components first.")
@@ -63,7 +63,7 @@ module RailsAiContext
63
63
  text_response(render_single(found.first, detail))
64
64
  else
65
65
  if components.empty?
66
- note = api_only_note("app/components")
66
+ note = api_only_note("app/components", dir: "app/components")
67
67
  return text_response(note) if note
68
68
 
69
69
  return text_response(
@@ -210,8 +210,11 @@ module RailsAiContext
210
210
  else
211
211
  result = "<%= render #{name}.new#{init == ".new" ? "" : init} do |c| %>"
212
212
  slots.each do |slot|
213
- if slot[:type] == :many
214
- result += "\n <% c.with_#{slot[:name]} do %>item<% end %>"
213
+ if slot[:setters]
214
+ filler = slot[:type] == :many ? "item" : "content"
215
+ slot[:setters].each { |setter| result += "\n <% c.with_#{setter} do %>#{filler}<% end %>" }
216
+ elsif slot[:type] == :many
217
+ result += "\n <% c.with_#{slot[:name].to_s.singularize} do %>item<% end %>"
215
218
  else
216
219
  result += "\n <% c.with_#{slot[:name]} do %>content<% end %>"
217
220
  end
@@ -171,8 +171,8 @@ module RailsAiContext
171
171
  end
172
172
 
173
173
  # Only the module's own mixins: a class nested in the file includes for itself.
174
- mixins = ConcernMembership.own_mixins(
175
- Introspectors::SourceIntrospector.walk_source(source, { mixins: Introspectors::Listeners::MixinsListener })[:mixins], name
174
+ mixins = ConcernMembership.owned_by(
175
+ Introspectors::SourceIntrospector.walk_source(source, { mixins: Introspectors::Listeners::MixinsListener })[:mixins], name, root: root
176
176
  )
177
177
  included_modules = mixins.select { |m| m[:macro] == :include }.map { |m| m[:name] }.uniq
178
178
  extended_modules = mixins.select { |m| m[:macro] == :extend }.map { |m| m[:name] }.uniq
@@ -190,14 +190,16 @@ module RailsAiContext
190
190
  macros.each { |m| lines << "- #{m}" }
191
191
  end
192
192
 
193
- public_methods = Introspectors::ActionResolver.public_methods_from_source(source, owner: name)
193
+ public_methods = unique(Introspectors::ActionResolver.public_methods_in(source, owner: name))
194
194
  class_methods = concern_class_methods(source, name)
195
+ own_module_methods = module_methods(source, name)
195
196
  render_methods(lines, source, detail, "Public Methods", public_methods)
196
- render_methods(lines, source, detail, "Class Methods", class_methods, self_prefix: true)
197
+ render_methods(lines, source, detail, "Class Methods", class_methods)
198
+ render_methods(lines, source, detail, "Module Methods", own_module_methods)
197
199
  # A module of private helpers is not an empty one.
198
- if public_methods.empty? && class_methods.empty?
200
+ if public_methods.empty? && class_methods.empty? && own_module_methods.empty?
199
201
  render_methods(lines, source, detail, "Private Methods",
200
- Introspectors::ActionResolver.private_methods_from_source(source, owner: name))
202
+ unique(Introspectors::ActionResolver.private_methods_in(source, owner: name)))
201
203
  end
202
204
 
203
205
  # Parse callbacks defined in the concern
@@ -246,29 +248,40 @@ module RailsAiContext
246
248
  text_response(lines.join("\n"))
247
249
  end
248
250
 
249
- # A class method is written `def self.x` but listed as `x`, so its body
250
- # is only found under the prefixed name.
251
- private_class_method def self.render_methods(lines, source, detail, title, methods, self_prefix: false)
251
+ # The body comes from this def's own walked bounds, so a same-named private method or delegate is never shown.
252
+ private_class_method def self.render_methods(lines, source, detail, title, methods)
252
253
  return if methods.empty?
253
254
 
254
255
  lines << "" << "## #{title}"
255
256
  full = RailsAiContext::DetailLevel.full?(detail)
256
257
  methods.each do |m|
257
- method_name = m.to_s.split("(").first
258
- body = full && (extract_method_source_from_string(source, method_name) ||
259
- (self_prefix ? extract_method_source_from_string(source, "self.#{method_name}") : nil))
258
+ signature = Introspectors::ActionResolver.signature(m)
259
+ body = full && Introspectors::ActionResolver.body_of(source, m)
260
260
  if body
261
- lines << "### #{m}" << "```ruby" << body[:code] << "```" << ""
261
+ lines << "### #{signature}" << "```ruby" << body[:code] << "```" << ""
262
262
  else
263
- lines << "- `#{m}`"
263
+ lines << "- `#{signature}`"
264
264
  end
265
265
  end
266
266
  end
267
267
 
268
268
  # `module ClassMethods` defs count too: ActiveSupport::Concern extends the includer with it.
269
269
  private_class_method def self.concern_class_methods(source, name)
270
- (Introspectors::ActionResolver.class_methods_from_source(source, owner: name) +
271
- Introspectors::ActionResolver.public_methods_from_source(source, owner: "#{name}::ClassMethods")).uniq
270
+ gained = own_class_methods(source, name).select { |m| m[:class_methods_block] }
271
+ unique(gained + Introspectors::ActionResolver.public_methods_in(source, owner: "#{name}::ClassMethods"))
272
+ end
273
+
274
+ # `def self.x` and `class << self` methods live on the module; no includer gains them, nor a mixin hook.
275
+ private_class_method def self.module_methods(source, name)
276
+ unique(own_class_methods(source, name).reject { |m| m[:class_methods_block] || ConcernMembership::MIXIN_HOOKS.include?(m[:name]) })
277
+ end
278
+
279
+ private_class_method def self.unique(methods)
280
+ methods.uniq { |m| Introspectors::ActionResolver.signature(m) }
281
+ end
282
+
283
+ private_class_method def self.own_class_methods(source, name)
284
+ Introspectors::ActionResolver.own_methods_in(source, name).select { |m| m[:scope] == :class && m[:visibility] == :public }
272
285
  end
273
286
 
274
287
  private_class_method def self.methods_phrase(concern)
@@ -299,7 +312,7 @@ module RailsAiContext
299
312
  if source
300
313
  public_methods = Introspectors::ActionResolver.public_methods_from_source(source, owner: concern_name)
301
314
  class_methods = concern_class_methods(source, concern_name)
302
- method_count = public_methods.size + class_methods.size
315
+ method_count = public_methods.size + class_methods.size + module_methods(source, concern_name).size
303
316
  private_count = Introspectors::ActionResolver.private_methods_from_source(source, owner: concern_name).size if method_count.zero?
304
317
  end
305
318
 
@@ -507,7 +520,7 @@ module RailsAiContext
507
520
  sources = includer_dirs(root).flat_map { |dir| safe_glob(dir, "**/*.rb", real_root) }.uniq
508
521
  .reject { |file_path| file_path.include?("/concerns/") }
509
522
  .filter_map { |file_path| (source = RailsAiContext::SafeFile.read(file_path)) && [ file_path, source ] }
510
- Introspectors::Includers.of(root, sources, [ concern_name ], macros: %i[include]).values.flatten.uniq.sort
523
+ Introspectors::Includers.of(root, sources, [ concern_name ], macros: %i[include prepend]).values.flatten.uniq.sort
511
524
  rescue => e
512
525
  RailsAiContext.debug_fail(e, [], label: "find_includers")
513
526
  end
@@ -19,6 +19,10 @@ module RailsAiContext
19
19
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
20
20
 
21
21
  def self.call(server_context: nil)
22
+ if static_refusal_for(:config) && (rackup = rackup_lines).any?
23
+ return text_response(([ unavailable_text ] + rackup).join("\n"))
24
+ end
25
+
22
26
  fetch_section(:config, subject: "Config introspection", remedy: "Add :config to introspectors or use `config.preset = :full`.") do |data|
23
27
  lines = [ "# Application Configuration", "" ]
24
28
 
@@ -59,6 +63,7 @@ module RailsAiContext
59
63
  end
60
64
 
61
65
  lines.concat(middleware_lines(data[:middleware_stack]))
66
+ lines.concat(rackup_lines)
62
67
 
63
68
  if data[:initializers]&.any?
64
69
  # List every initializer - stock ones often carry active code
@@ -72,13 +77,23 @@ module RailsAiContext
72
77
 
73
78
  if data[:current_attributes]&.any?
74
79
  lines << "" << "## CurrentAttributes"
75
- data[:current_attributes].each { |c| lines << "- `#{c}`" }
80
+ data[:current_attributes].each { |c| lines << current_attributes_line(c, data.dig(:current_attribute_details, c)) }
76
81
  end
77
82
 
78
83
  text_response(lines.join("\n"))
79
84
  end
80
85
  end
81
86
 
87
+ private_class_method def self.current_attributes_line(name, detail)
88
+ line = "- `#{name}`"
89
+ return line unless detail
90
+
91
+ attributes = Array(detail[:attributes]).map { |a| a[:default] ? "#{a[:name]} (default `#{a[:default]}`)" : a[:name] }
92
+ line += ": #{attributes.join(', ')}" if attributes.any?
93
+ line += "; hooks: #{detail[:hooks].map { |h| "`#{h}`" }.join(', ')}" if Array(detail[:hooks]).any?
94
+ line
95
+ end
96
+
82
97
  # Middleware Rails or a development gem puts in every stack.
83
98
  DEV_MIDDLEWARE = %w[
84
99
  Propshaft::Server WebConsole::Middleware ActionDispatch::Reloader
@@ -110,6 +125,30 @@ module RailsAiContext
110
125
  lines
111
126
  end
112
127
 
128
+ # The static refusal reads config.ru alone rather than building every section,
129
+ # and only when the middleware introspector the booted tier reads it through is on.
130
+ private_class_method def self.rackup_lines
131
+ calls = if RailsAiContext.static_tier?
132
+ RailsAiContext.configuration.introspectors.include?(:middleware) ? Introspectors::MiddlewareIntrospector.rackup(rails_app.root.to_s) : []
133
+ else
134
+ Array(Payload.section(cached_context, :middleware)&.dig(:rackup))
135
+ end
136
+ return [] if calls.empty?
137
+
138
+ lines = [ "", "## config.ru (runs before the Rails middleware stack)" ]
139
+ calls.each do |call|
140
+ next lines << "- not read: #{call[:unread]}, so the middleware it adds is unknown" if call[:unread]
141
+
142
+ where = [ "line #{call[:line]}", ("inside `map #{call[:within]}`" if call[:within]), call[:condition] ].compact.join(", ")
143
+ lines << if call[:call] == "map"
144
+ "- `map #{call[:target]}` (#{where}) - its own Rack app; requests under it never reach Rails' router"
145
+ else
146
+ "- `use #{call[:target]}` (#{where})"
147
+ end
148
+ end
149
+ lines
150
+ end
151
+
113
152
  # What the middleware introspector found the app owns, by class name.
114
153
  private_class_method def self.owned_middleware
115
154
  Array(Payload.section(cached_context, :middleware)&.dig(:custom_middleware))
@@ -155,9 +194,11 @@ module RailsAiContext
155
194
  end
156
195
 
157
196
  private_class_method def self.detect_database
158
- adapter = Rails.configuration.database_configuration&.dig(Rails.env, "adapter") rescue nil
159
- return nil unless adapter
160
- adapter
197
+ env = Rails.configuration.database_configuration&.dig(Rails.env) rescue nil
198
+ return nil unless env.is_a?(Hash)
199
+
200
+ # A multi-database environment nests one block per database; primary is the app's own.
201
+ env["adapter"] || (env["primary"] || env.values.find { |v| v.is_a?(Hash) })&.dig("adapter")
161
202
  end
162
203
 
163
204
  private_class_method def self.detect_auth_framework
@@ -214,23 +255,16 @@ module RailsAiContext
214
255
  parts.any? ? parts.join(", ") : nil
215
256
  end
216
257
 
258
+ # Action Cable's railtie loads the current environment's cable.yml block into
259
+ # the server config; config.action_cable has no adapter of its own.
217
260
  private_class_method def self.detect_action_cable
218
- # Try Rails config API first
219
- if defined?(ActionCable) && Rails.application.config.respond_to?(:action_cable)
220
- cable_config = Rails.application.config.action_cable
221
- adapter = cable_config.adapter if cable_config.respond_to?(:adapter)
222
- return adapter.to_s if adapter && !adapter.to_s.empty?
223
- end
224
-
225
- # YAML fallback for older Rails or when config API isn't available
226
- cable_yml = rails_app.root.join("config/cable.yml")
227
- return nil unless File.exist?(cable_yml)
261
+ return nil unless defined?(ActionCable) && ActionCable.respond_to?(:server)
228
262
 
229
- content = RailsAiContext::SafeFile.read(cable_yml)
230
- return nil unless content
263
+ cable = ActionCable.server.config.cable
264
+ return nil unless cable
231
265
 
232
- adapter = content.match(/adapter:\s*(\w+)/)&.captures&.first
233
- adapter || "configured"
266
+ adapter = cable[:adapter] || cable["adapter"]
267
+ adapter.to_s.empty? ? "configured" : adapter.to_s
234
268
  rescue StandardError
235
269
  nil
236
270
  end
@@ -56,8 +56,9 @@ module RailsAiContext
56
56
  key = Payload.find_controller(ctx, controller) || controller
57
57
  info = controllers[key]
58
58
  unless info
59
+ recovery = "Call rails_get_controllers(detail:\"summary\") to see all controllers"
59
60
  return not_found_response("Controller", controller, app_controller_names,
60
- recovery_tool: "Call rails_get_controllers(detail:\"summary\") to see all controllers")
61
+ recovery_tool: data[:unread_engine] ? "#{unread_engine_line(data)} #{recovery}" : recovery)
61
62
  end
62
63
  return text_response("Error inspecting #{key}: #{info[:error]}") if info[:error]
63
64
 
@@ -85,7 +86,7 @@ module RailsAiContext
85
86
  # Listing mode
86
87
  case detail
87
88
  when "summary"
88
- lines = [ "# Controllers (#{page[:total]})", "" ]
89
+ lines = listing_header(page, data)
89
90
  paginated_names.each do |name|
90
91
  info = app_controllers[name]
91
92
  action_count = info[:actions]&.size || 0
@@ -96,7 +97,7 @@ module RailsAiContext
96
97
  text_response(lines.join("\n"))
97
98
 
98
99
  when "standard"
99
- lines = [ "# Controllers (#{page[:total]})", "" ]
100
+ lines = listing_header(page, data)
100
101
  paginated_names.each do |name|
101
102
  info = app_controllers[name]
102
103
  lines << "- **#{name}** - #{Serializers::SectionFacts.actions_phrase(info)}"
@@ -105,7 +106,7 @@ module RailsAiContext
105
106
  text_response(lines.join("\n"))
106
107
 
107
108
  when "full"
108
- lines = [ "# Controllers (#{page[:total]})", "" ]
109
+ lines = listing_header(page, data)
109
110
 
110
111
  # Group sibling controllers that share the same parent and identical structure
111
112
  paginated_ctrl = app_controllers.select { |k, _| paginated_names.include?(k) }
@@ -151,7 +152,9 @@ module RailsAiContext
151
152
  info, rescue_handlers: true,
152
153
  ctx: ctx, name: name, root: rails_app&.root&.to_s
153
154
  ))
154
- lines << "- Rate limit: #{info[:rate_limit]}" if info[:rate_limit]
155
+ # An inherited limit is named with its base in the controller's own detail.
156
+ own_limits = Array(info[:rate_limits]).reject { |limit| limit[:from] }
157
+ lines << "- Rate limit: #{own_limits.map { |limit| limit[:text] }.join('; ')}" if own_limits.any?
155
158
  lines << "- Turbo Stream actions: #{info[:turbo_stream_actions].join(', ')}" if info[:turbo_stream_actions]&.any?
156
159
  lines << ""
157
160
  end
@@ -174,6 +177,14 @@ module RailsAiContext
174
177
  )
175
178
  end
176
179
 
180
+ private_class_method def self.listing_header(page, data)
181
+ [ "# Controllers (#{page[:total]})", "", *(data[:unread_engine] ? [ unread_engine_line(data), "" ] : []) ]
182
+ end
183
+
184
+ private_class_method def self.unread_engine_line(data)
185
+ "The engine at `#{data[:unread_engine]}` is not read unbooted, so its controllers are not listed; boot the app to read them."
186
+ end
187
+
177
188
  # A class that defines no action of its own is an answer, and dropping
178
189
  # the line left it reading as a walk that did not look. An unread entry
179
190
  # already gets a line of its own, so it is not said twice.
@@ -234,8 +245,7 @@ module RailsAiContext
234
245
 
235
246
  if applicable.values.any?(&:any?)
236
247
  lines << "" << "## Applicable Filters"
237
- applicable[:inherited].each { |f| lines << filter_line(f) }
238
- applicable[:own].each { |f| lines << filter_line(f) }
248
+ applicable[:chain].each { |f| lines << filter_line(f) }
239
249
  applicable[:skipped].each { |name| lines << "- ~~#{name}~~ _(skipped)_" }
240
250
  end
241
251
 
@@ -289,9 +299,11 @@ module RailsAiContext
289
299
  lines << "- requires: `:#{sp[:requires]}`" if sp[:requires]
290
300
  lines << "- permits: #{sp[:permits].map { |p| "`:#{p}`" }.join(', ')}" if sp[:permits]&.any?
291
301
  sp[:nested]&.each do |key, fields|
292
- lines << "- nested `#{key}:` #{fields.map { |f| "`:#{f}`" }.join(', ')}"
302
+ members = fields.is_a?(Hash) ? fields.map { |inner, v| permit_field_text({ inner => v }) } : fields.map { |f| permit_field_text(f, braced: true) }
303
+ lines << "- nested `#{key}:` #{members.map { |m| "`#{m}`" }.join(', ')}"
293
304
  end
294
305
  sp[:arrays]&.each { |a| lines << "- array: `#{a}: []`" }
306
+ sp[:hashes]&.each { |h| lines << "- hash: `#{h}: {}`" }
295
307
  end
296
308
  body = extract_method_with_lines(source_path, sp[:name], source: source, owner: controller_name)
297
309
  lines << "```ruby" << body[:code] << "```" if body
@@ -359,6 +371,20 @@ module RailsAiContext
359
371
  end
360
372
  end
361
373
 
374
+ # One field of a permit list as Ruby spells it: `:name`, `[...]`, `key: [...]`, `key: {}` or `key: { ... }`, braced inside a list.
375
+ private_class_method def self.permit_field_text(field, braced: false)
376
+ return "[#{field.map { |f| permit_field_text(f, braced: true) }.join(', ')}]" if field.is_a?(Array)
377
+ return ":#{field}" unless field.is_a?(Hash)
378
+
379
+ key, value = field.first
380
+ text = if value.is_a?(Hash)
381
+ value.empty? ? "#{key}: {}" : "#{key}: { #{value.map { |inner, v| permit_field_text({ inner => v }) }.join(', ')} }"
382
+ else
383
+ "#{key}: [#{value.map { |f| permit_field_text(f, braced: true) }.join(', ')}]"
384
+ end
385
+ braced ? "{ #{text} }" : text
386
+ end
387
+
362
388
  private_class_method def self.filter_line(filter)
363
389
  line = "- `#{filter[:kind]}` **#{filter[:name]}**#{Serializers::SectionFacts.filter_origin(filter)}"
364
390
  line += " (only: #{filter[:only].join(', ')})" if filter[:only]&.any?
@@ -438,6 +464,8 @@ module RailsAiContext
438
464
  lines << "**Parent:** `#{resolved_parent(name, info, ctx)}`" if info[:parent_class]
439
465
  lines << "**API controller:** yes" if info[:api_controller]
440
466
  lines << "**Formats:** #{info[:respond_to_formats].join(', ')}" if info[:respond_to_formats]&.any?
467
+ declared = RailsAiContext::Introspectors::ControllerSettings.resolve(ctx, name, root: rails_app.root.to_s)
468
+ lines << "**Layout:** #{RailsAiContext::Introspectors::ControllerSettings.layout_phrase(declared[:layout])}" if declared[:layout]
441
469
 
442
470
  lines << "" << "## Actions"
443
471
  lines << if info[:actions]&.any?
@@ -449,21 +477,23 @@ module RailsAiContext
449
477
  chain = RailsAiContext::ActionFilters.for_controller(ctx, name, root: rails_app.root.to_s)
450
478
  if chain.values.any?(&:any?)
451
479
  lines << "" << "## Filters"
452
- chain[:inherited].each { |f| lines << filter_line(f) }
453
- chain[:own].each { |f| lines << filter_line(f) }
480
+ chain[:chain].each { |f| lines << filter_line(f) }
454
481
  chain[:skipped].each { |skipped| lines << "- ~~#{skipped}~~ _(skipped)_" }
455
482
  end
456
483
  if info[:concerns_unread]&.any?
457
484
  lines << "" << "_#{RailsAiContext::Confidence::UNAVAILABLE} " \
458
485
  "#{CountPhrase.call(info[:concerns_unread].size, "included module")} not read, " \
459
- "so a filter declared there is missing from this list: #{info[:concerns_unread].join(', ')}_"
486
+ "so a filter declared there would be missing from this list: #{info[:concerns_unread].join(', ')}_"
460
487
  end
461
488
 
462
489
  if info[:strong_params]&.any?
463
490
  lines << "" << "## Strong Params"
464
491
  info[:strong_params].each do |sp|
465
492
  if sp.is_a?(Hash)
466
- permits_summary = sp[:permits]&.map { |p| ":#{p}" }&.join(", ") || ""
493
+ permits_summary = (Array(sp[:permits]).map { |p| ":#{p}" } +
494
+ Array(sp[:nested]).map { |key, fields| permit_field_text({ key => fields }) } +
495
+ Array(sp[:arrays]).map { |key| "#{key}: []" } +
496
+ Array(sp[:hashes]).map { |key| "#{key}: {}" }).join(", ")
467
497
  lines << "- `#{sp[:name]}`#{sp[:requires] ? " (requires: :#{sp[:requires]})" : ""}#{permits_summary.empty? ? "" : " permits: #{permits_summary}"}"
468
498
  else
469
499
  lines << "- `#{sp}`"
@@ -471,6 +501,14 @@ module RailsAiContext
471
501
  end
472
502
  end
473
503
 
504
+ if declared[:settings].any?
505
+ lines << "" << "## Settings"
506
+ declared[:settings].each do |setting|
507
+ origin = [ ("from #{setting[:from]}" unless setting[:from] == name), ("through #{setting[:via]}" if setting[:via]) ].compact
508
+ lines << "- `#{setting[:text]}`#{" _(#{origin.join(' ')})_" if origin.any?}"
509
+ end
510
+ end
511
+
474
512
  # Rescue handlers
475
513
  if info[:rescue_from]&.any?
476
514
  lines << "" << "## Rescue Handlers"
@@ -478,7 +516,13 @@ module RailsAiContext
478
516
  end
479
517
 
480
518
  # Rate limiting
481
- lines << "" << "**Rate limit:** #{info[:rate_limit]}" if info[:rate_limit]
519
+ limits = Array(info[:rate_limits]).map { |limit| "#{limit[:text]}#{" _(from #{limit[:from]})_" if limit[:from]}" }
520
+ if limits.one?
521
+ lines << "" << "**Rate limit:** #{limits.first}"
522
+ elsif limits.any?
523
+ lines << "" << "**Rate limits:**"
524
+ limits.each { |limit| lines << "- #{limit}" }
525
+ end
482
526
 
483
527
  # Turbo Stream actions
484
528
  if info[:turbo_stream_actions]&.any?
@@ -64,6 +64,12 @@ module RailsAiContext
64
64
  end
65
65
  end
66
66
 
67
+ admin = Introspectors::AdminResources.call(rails_app.root)
68
+ if admin.any?
69
+ lines << "" << "## Admin Resources"
70
+ admin.each { |resource| lines << "- #{Introspectors::AdminResources.line(resource)}" }
71
+ end
72
+
67
73
  # Config files - only show non-obvious ones (skip files every Rails app has)
68
74
  if conventions[:config_files]&.any?
69
75
  obvious = %w[
@@ -164,6 +170,7 @@ module RailsAiContext
164
170
  [ "esbuild", %w[esbuild] ],
165
171
  [ "Vite", %w[vite] ],
166
172
  [ "Webpack", %w[webpack] ],
173
+ [ "Rollup", %w[rollup] ],
167
174
  [ "React", %w[react] ],
168
175
  [ "Vue", %w[vue] ],
169
176
  [ "Svelte", %w[svelte @sveltejs/kit] ],
@@ -174,26 +181,17 @@ module RailsAiContext
174
181
 
175
182
  private_class_method def self.detect_frontend_stack
176
183
  root = rails_app.root.to_s
177
- return [] unless File.exist?(File.join(root, "package.json"))
178
-
184
+ css = Introspectors::AssetPipelineIntrospector.css_framework_label(root)
179
185
  stack = STACK_MARKERS.filter_map do |label, packages|
180
- label if packages.any? { |pkg| RailsAiContext::PackageJson.present?(root, pkg) }
186
+ label if label == css || packages.any? { |pkg| RailsAiContext::PackageJson.present?(root, pkg) }
181
187
  end
182
188
 
183
- pm = detect_package_manager
189
+ pm = RailsAiContext::PackageJson.package_manager(root)&.first
184
190
  stack << "#{pm} (package manager)" if pm
185
191
 
186
192
  stack
187
193
  end
188
194
 
189
- private_class_method def self.detect_package_manager
190
- return "pnpm" if File.exist?(rails_app.root.join("pnpm-lock.yaml"))
191
- return "yarn" if File.exist?(rails_app.root.join("yarn.lock"))
192
- return "bun" if File.exist?(rails_app.root.join("bun.lockb"))
193
- return "npm" if File.exist?(rails_app.root.join("package-lock.json"))
194
- nil
195
- end
196
-
197
195
  # Scan controllers for app-specific authorization, flash, and error-handling patterns
198
196
  private_class_method def self.detect_app_patterns
199
197
  controllers_dir = rails_app.root.join("app", "controllers").to_s
@@ -224,19 +222,17 @@ module RailsAiContext
224
222
  flash_alerts.concat(content.scan(/alert:\s*"([^"]*)"/).flatten)
225
223
 
226
224
  # Not-found handling: set_* methods that rescue or redirect on missing records
227
- content.scan(/def\s+(set_\w+).*?(?=\n\s*def\s|\n\s*end\s*\z)/m).each do |match_data|
228
- method_name = match_data[0]
229
- # Look for the block around this method for rescue/redirect
230
- if content.match?(/def\s+#{Regexp.escape(method_name)}.*?(?:rescue\s+ActiveRecord::RecordNotFound|rescue\b)/m)
231
- redirect_match = content.match(/def\s+#{Regexp.escape(method_name)}.*?redirect_to\s+(\S+)/m)
225
+ content.scan(/def\s+(set_\w+)(.*?)(?=\n\s*def\s|\n\s*end\s*\z)/m).each do |method_name, body|
226
+ if body.match?(/\brescue\b/)
227
+ redirect_match = body.match(/redirect_to\s+(\S+)/)
232
228
  target = redirect_match ? redirect_match[1].sub(/,.*/, "") : "..."
233
229
  not_found_patterns << "#{method_name} → rescue → redirect_to #{target}" unless not_found_patterns.include?("#{method_name} → rescue → redirect_to #{target}")
234
230
  end
235
231
  end
236
232
 
237
233
  # Create action flow detection
238
- if content.match?(/def\s+create\b/)
239
- create_block = content[/def\s+create\b.*?(?=\n\s{2}def\s|\n\s{2}private|\z)/m]
234
+ if content.match?(/def\s+create#{RailsAiContext::MethodName.definition_end("create")}/)
235
+ create_block = content[/def\s+create#{RailsAiContext::MethodName.definition_end("create")}.*?(?=\n\s{2}def\s|\n\s{2}private|\z)/m]
240
236
  if create_block
241
237
  flow_parts = []
242
238
  flow_parts << "permission check" if create_block.match?(/can_\w+\??|authorize|authorize!/)
@@ -395,7 +391,7 @@ module RailsAiContext
395
391
  return info unless Dir.exist?(locales_dir)
396
392
 
397
393
  real_root = File.realpath(rails_app.root).to_s
398
- locale_files = safe_glob(locales_dir, "**/*.{yml,yaml,rb}", real_root)
394
+ locale_files = safe_glob(locales_dir, Introspectors::I18nIntrospector::RAILS_LOCALE_GLOB, real_root)
399
395
  # The locales each file declares, as the i18n section read them; a
400
396
  # file's name gives the locale only as its last part (devise.en.yml).
401
397
  read = RailsAiContext::Payload.section(cached_context, :i18n)&.dig(:locale_files)
@@ -451,7 +447,7 @@ module RailsAiContext
451
447
 
452
448
  TEST_SIGNALS = {
453
449
  devise: /Devise::Test::IntegrationHelpers/,
454
- sign_in: /sign_in/,
450
+ sign_in: /\bsign_in(?:_as)?\b/,
455
451
  assert_select: /assert_select/,
456
452
  assert_response: /assert_response/,
457
453
  auth_test: /requires?\s+authentication/i
@@ -469,6 +465,7 @@ module RailsAiContext
469
465
 
470
466
  found = {}
471
467
  detected_in = []
468
+ sign_in = nil
472
469
 
473
470
  test_files.first(5).each do |path|
474
471
  content = RailsAiContext::SafeFile.read(path) or next
@@ -476,6 +473,7 @@ module RailsAiContext
476
473
  next if hits.empty?
477
474
 
478
475
  found.update(hits)
476
+ sign_in ||= sign_in_call(content) if hits[:sign_in]
479
477
  # Only credit files that actually contributed a signal - an empty
480
478
  # scaffold test says nothing about the app's test pattern.
481
479
  detected_in << File.basename(path, ".rb").camelize.sub(/Test$/, "")
@@ -498,7 +496,7 @@ module RailsAiContext
498
496
  sections << ""
499
497
  end
500
498
  sections << " test \"[action] renders page\" do"
501
- sections << " sign_in users(:one)" if found[:sign_in]
499
+ sections << " #{sign_in}" if sign_in
502
500
  sections << " get [path]"
503
501
  sections << " assert_response :success"
504
502
  sections << " assert_select \"h1\", \"[Expected Title]\"" if found[:assert_select]
@@ -512,6 +510,30 @@ module RailsAiContext
512
510
  rescue => e
513
511
  RailsAiContext.debug_fail(e, [], label: "detect_test_pattern")
514
512
  end
513
+
514
+ # A variable or a bare helper name: set outside the call, so a copy of
515
+ # the call alone signs in nil.
516
+ SIGN_IN_BOUND_OUTSIDE = /\A(?:@@?|\$)?[a-z_]\w*\z/
517
+ private_constant :SIGN_IN_BOUND_OUTSIDE
518
+
519
+ # The first sign-in call as the test writes it: Devise's `sign_in` and
520
+ # the `sign_in_as` Rails 8.1's authentication generator defines take
521
+ # different arguments. An argument the skeleton never sets is swapped
522
+ # for the app's own users fixture.
523
+ private_class_method def self.sign_in_call(content)
524
+ hit = RailsAiContext::Introspectors::SourceIntrospector.walk_source(content, {
525
+ sign_in: -> { RailsAiContext::Introspectors::Listeners::GenericMacroListener.new(:sign_in, :sign_in_as) }
526
+ })[:sign_in].first or return nil
527
+
528
+ call = content.byteslice(hit[:offset], hit[:end_offset] - hit[:offset]).squish
529
+ arg = hit[:values].first
530
+ return call unless arg.is_a?(String) && arg.match?(SIGN_IN_BOUND_OUTSIDE)
531
+
532
+ key = fixture_key_for("users", RailsAiContext::Payload.section(cached_context, :tests) || {})
533
+ return "# TODO: #{hit[:macro]} a user built from this app's own test data" unless key
534
+
535
+ call.sub(/(?<![\w@$])#{Regexp.escape(arg)}(?![\w?!])/, "users(:#{key})")
536
+ end
515
537
  end
516
538
  end
517
539
  end
@@ -31,6 +31,7 @@ module RailsAiContext
31
31
  if mounted.any?
32
32
  mounted.each do |e|
33
33
  line = e[:path] ? "- **#{e[:engine]}** at `#{e[:path]}`" : "- **#{e[:engine]}**"
34
+ line += " (`#{e[:condition]}`)" if e[:condition]
34
35
  line += " (#{e[:category]})" if e[:category]
35
36
  line += " - #{e[:description]}" if e[:description]
36
37
  lines << line
@@ -56,6 +57,7 @@ module RailsAiContext
56
57
  loaded.each do |e|
57
58
  parts = []
58
59
  parts << count_phrase(e[:route_count], "route") if e[:route_count]
60
+ parts << count_phrase(e[:dynamic_route_count], "redirect or lambda route") if e[:dynamic_route_count]
59
61
  parts << count_phrase(e[:model_count], "model") if e[:model_count]
60
62
  line = "- **#{e[:name]}**"
61
63
  line += " - #{parts.join(', ')}" if parts.any?