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
@@ -98,14 +98,16 @@ module RailsAiContext
98
98
  lines << ""
99
99
  end
100
100
 
101
- # Check if table exists
102
- table_exists = schema && schema[:tables]&.key?(table)
101
+ databases = RailsAiContext::Payload.schema_databases(schema, table)
102
+ table_exists = databases.any?
103
+ database_lines, target = database_option(table, databases)
104
+ lines.concat(database_lines)
103
105
 
104
106
  case action
105
107
  when "add_column"
106
- lines.concat(generate_add_column(table, column, type, options, table_exists))
108
+ lines.concat(generate_add_column(table, column, type, options, table_exists, target))
107
109
  when "remove_column"
108
- lines.concat(generate_remove_column(table, column, type, schema, models))
110
+ lines.concat(generate_remove_column(table, column, type, schema, models, target))
109
111
  when "rename_column"
110
112
  rename_to = new_name&.to_s&.strip
111
113
  rename_to = type if rename_to.nil? || rename_to.empty?
@@ -113,7 +115,7 @@ module RailsAiContext
113
115
  when "add_index"
114
116
  lines.concat(generate_add_index(table, column, options))
115
117
  when "add_association"
116
- lines.concat(generate_add_association(table, column, type, options))
118
+ lines.concat(generate_add_association(table, column, type, options, schema))
117
119
  when "change_type"
118
120
  lines.concat(generate_change_type(table, column, type, options))
119
121
  when "create_table"
@@ -141,7 +143,42 @@ module RailsAiContext
141
143
  parts.join
142
144
  end
143
145
 
144
- def generate_add_column(table, column, type, options, table_exists)
146
+ # A table only a secondary database holds needs its migration in that database's migrations_paths.
147
+ # [lines, target]: target is nil for the primary, false when no environment configures the
148
+ # database, else the generator's --database flag and the RAILS_ENV that configures it.
149
+ def database_option(table, databases)
150
+ return [ [], nil ] if databases.empty? || databases.include?("primary")
151
+
152
+ yml = RailsAiContext::DatabaseYml
153
+ root = rails_app.root
154
+ found = databases.to_h { |db| [ db, (entry = yml.entry(root, db)) ? [ nil, entry ] : yml.elsewhere(root, db) ] }.compact
155
+ if found.empty?
156
+ note = "**Database:** `#{table}` is in the #{databases.join(", ")} dump, which no environment in config/database.yml configures: " \
157
+ "no `--database` reaches it, and a migration generated here runs on the primary."
158
+ return [ [ note, "" ], false ]
159
+ end
160
+
161
+ names = found.keys
162
+ env_name = found.values.first.first
163
+ target = { flag: " --database #{names.first}", env: env_name }
164
+ paths = Array(found.values.first.last["migrations_paths"]).join(", ")
165
+ note = "**Database:** `#{table}` is in #{[ names[0..-2].join(", "), names.last ].reject(&:empty?).join(" and ")}," \
166
+ "#{" which #{env_name} configures and this environment does not," if env_name} not the primary database: generate with #{"`RAILS_ENV=#{env_name}` and " if env_name}`#{target[:flag].strip}` " \
167
+ "so the migration lands in #{env_name && !paths.empty? ? paths : "that database's migrations_paths"} and " \
168
+ "#{"#{env_name}'s " if env_name}`bin/rails db:migrate` runs it there."
169
+ groups = found.group_by { |_, (_, entry)| Array(entry["migrations_paths"]) }
170
+ if groups.size > 1
171
+ note += " Those databases read different migrations_paths, so generate it once per database: " \
172
+ "#{groups.values.map { |dbs| "`--database #{dbs.first.first}`" }.join(', ')}."
173
+ end
174
+ [ [ note, "" ], target ]
175
+ end
176
+
177
+ def generate_command(args, target)
178
+ "#{"RAILS_ENV=#{target[:env]} " if target && target[:env]}bin/rails generate migration #{args}#{target && target[:flag]}"
179
+ end
180
+
181
+ def generate_add_column(table, column, type, options, table_exists, target = nil)
145
182
  return [ "**Error:** column name is required for add_column" ] unless column
146
183
  type ||= "string"
147
184
 
@@ -159,10 +196,10 @@ module RailsAiContext
159
196
  opts = options ? ", #{options}" : ""
160
197
  class_name = migration_class_name("add", table, column)
161
198
 
162
- lines << "**Run:** `bin/rails generate migration #{class_name} #{column}:#{type}`"
163
- lines << ""
199
+ command = generate_command("#{class_name} #{column}:#{type}", target) unless target == false
200
+ lines.push("**Run:** `#{command}`", "") if command
164
201
  lines << "```ruby"
165
- lines << "# rails generate migration #{class_name} #{column}:#{type}"
202
+ lines << "# #{command.sub("bin/rails", "rails")}" if command
166
203
  lines << "class #{class_name} < ActiveRecord::Migration[#{rails_version}]"
167
204
  lines << " def change"
168
205
  lines << " add_column :#{table}, :#{column}, :#{type}#{opts}"
@@ -175,12 +212,12 @@ module RailsAiContext
175
212
  lines
176
213
  end
177
214
 
178
- def generate_remove_column(table, column, type, schema, models)
215
+ def generate_remove_column(table, column, type, schema, models, target = nil)
179
216
  return [ "**Error:** column name is required for remove_column" ] unless column
180
217
 
181
218
  lines = []
182
219
 
183
- table_exists = schema && schema[:tables]&.key?(table)
220
+ table_exists = !RailsAiContext::Payload.schema_table(schema, table).nil?
184
221
  unless table_exists
185
222
  lines << "**Warning:** Table `#{table}` not found in current schema. This migration will fail."
186
223
  lines << ""
@@ -196,8 +233,7 @@ module RailsAiContext
196
233
  # Check if column is referenced
197
234
  col_type = find_column_type(table, column, schema) || type || "string"
198
235
 
199
- lines << "**Run:** `bin/rails generate migration #{class_name} #{column}:#{col_type}`"
200
- lines << ""
236
+ lines.push("**Run:** `#{generate_command("#{class_name} #{column}:#{col_type}", target)}`", "") unless target == false
201
237
  lines << "**Warning:** `remove_column` is irreversible without specifying the column type."
202
238
  lines << ""
203
239
  lines << "```ruby"
@@ -276,7 +312,7 @@ module RailsAiContext
276
312
  lines
277
313
  end
278
314
 
279
- def generate_add_association(table, column, type, options)
315
+ def generate_add_association(table, column, type, options, schema = nil)
280
316
  foreign_table = column || type
281
317
  return [ "**Error:** Specify the associated table in column param (e.g., column: 'users')" ] unless foreign_table
282
318
 
@@ -287,10 +323,20 @@ module RailsAiContext
287
323
  lines << ""
288
324
  end
289
325
 
326
+ to_table = foreign_table.singularize.pluralize
327
+ own = RailsAiContext::Payload.schema_databases(schema, table)
328
+ theirs = RailsAiContext::Payload.schema_databases(schema, to_table)
329
+ across = own.any? && theirs.any? && !own.intersect?(theirs)
330
+ if across
331
+ lines << "**Foreign key:** `#{to_table}` is in #{theirs.join(' and ')} and `#{table}` in #{own.join(' and ')}; " \
332
+ "a foreign key cannot reach another database, so the reference below has none."
333
+ lines << ""
334
+ end
335
+
290
336
  lines << "```ruby"
291
337
  lines << "class Add#{foreign_table.camelize}To#{table.camelize} < ActiveRecord::Migration[#{rails_version}]"
292
338
  lines << " def change"
293
- lines << " add_reference :#{table}, :#{foreign_table.singularize}, foreign_key: true"
339
+ lines << " add_reference :#{table}, :#{foreign_table.singularize}#{", foreign_key: true" unless across}"
294
340
  lines << " end"
295
341
  lines << "end"
296
342
  lines << "```"
@@ -495,9 +541,7 @@ module RailsAiContext
495
541
 
496
542
  def column_exists?(table, column)
497
543
  schema = cached_context[:schema]
498
- return false unless schema.is_a?(Hash) && schema[:tables]
499
-
500
- table_data = schema[:tables][table]
544
+ table_data = RailsAiContext::Payload.schema_table(schema, table)
501
545
  return false unless table_data
502
546
 
503
547
  col_str = column.to_s
@@ -506,9 +550,7 @@ module RailsAiContext
506
550
 
507
551
  def index_exists?(table, column)
508
552
  schema = cached_context[:schema]
509
- return false unless schema.is_a?(Hash) && schema[:tables]
510
-
511
- table_data = schema[:tables][table]
553
+ table_data = RailsAiContext::Payload.schema_table(schema, table)
512
554
  return false unless table_data
513
555
 
514
556
  # add_index collides only with the name it would give the index; a
@@ -521,9 +563,7 @@ module RailsAiContext
521
563
  end
522
564
 
523
565
  def find_column_type(table, column, schema)
524
- return nil unless schema.is_a?(Hash) && schema[:tables]
525
-
526
- table_data = schema[:tables][table]
566
+ table_data = RailsAiContext::Payload.schema_table(schema, table)
527
567
  return nil unless table_data
528
568
 
529
569
  col = (table_data[:columns] || []).find { |c| c[:name] == column }
@@ -5,7 +5,7 @@ module RailsAiContext
5
5
  class Onboard < BaseTool
6
6
  tool_name "rails_onboard"
7
7
  description "Get a narrative walkthrough of the Rails application - stack, data model, authentication, key flows, " \
8
- "background jobs, frontend, testing, and getting started instructions. " \
8
+ "background jobs, frontend, testing, getting started instructions, and the app's custom rake tasks, generators and Railties. " \
9
9
  "Use when: first encountering a project, onboarding a new developer, or orienting an AI agent. " \
10
10
  "Key params: detail (quick/standard/full)."
11
11
 
@@ -28,11 +28,14 @@ module RailsAiContext
28
28
 
29
29
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
30
30
 
31
- STANDARD_SECTIONS = %i[stack data_model auth key_flows jobs frontend testing getting_started].freeze
31
+ STANDARD_SECTIONS = %i[stack data_model auth key_flows jobs frontend testing getting_started rake_tasks generators].freeze
32
32
  FULL_SECTIONS = %i[
33
33
  stack data_model auth key_flows jobs frontend payments realtime storage api devops i18n engines env
34
- testing getting_started
34
+ testing getting_started all_rake_tasks generators
35
35
  ].freeze
36
+ AUTH_GEMS = %w[devise omniauth rodauth-rails sorcery clearance authlogic].freeze
37
+ AUTHZ_GEMS = %w[pundit cancancan action_policy rolify].freeze
38
+ RAKE_TASKS_SHOWN = 15
36
39
 
37
40
  def self.call(detail: "standard", server_context: nil)
38
41
  ctx = cached_context
@@ -58,14 +61,14 @@ module RailsAiContext
58
61
  def compose_quick(ctx)
59
62
  app = ctx[:app_name] || "This Rails app"
60
63
 
61
- version = named_ruby_version(ctx)
64
+ ruby = ruby_phrase(ctx)
62
65
  rails = named_rails_version(ctx)
63
66
  head = "**#{app}** is a Rails#{" #{rails}" if rails}"
64
67
  # The slash pairs two versions; with no Rails version to pair, the
65
68
  # Ruby one follows the noun instead of sitting in front of it.
66
- head += " / Ruby #{version}" if version && rails
69
+ head += " / #{ruby}" if ruby && rails
67
70
  parts = [ head, "app" ]
68
- parts << "running Ruby #{version}" if version && !rails
71
+ parts << "running #{ruby}" if ruby && !rails
69
72
 
70
73
  # Stats: tables, models, jobs
71
74
  stats = []
@@ -97,10 +100,18 @@ module RailsAiContext
97
100
  tests = Payload.section(ctx, :tests)
98
101
  if tests
99
102
  framework = tests[:framework]
100
- parts << (RailsAiContext::TestFramework.none?(framework) ? "with no tests yet" : "tested with #{framework || 'unknown framework'}")
103
+ parts << if RailsAiContext::TestFramework.none?(framework) then "with no tests yet"
104
+ elsif framework == RailsAiContext::TestFramework::NOT_READ then "with its tests not read"
105
+ else "tested with #{framework || 'unknown framework'}"
106
+ end
101
107
  end
102
108
 
103
- parts.join(" ") + "."
109
+ [ parts.join(" ") + ".", unread_bundle(rails) ].compact.join(" ")
110
+ end
111
+
112
+ def unread_bundle(rails)
113
+ unread = RailsAiContext::GemLock.for(rails_app.root).unread_bundle unless rails
114
+ "Its gems and Rails version are not read: #{unread}." if unread
104
115
  end
105
116
 
106
117
  def compose_standard(ctx)
@@ -129,11 +140,15 @@ module RailsAiContext
129
140
  elsif RailsAiContext::AppKind.mongoid?(rails_app.root)
130
141
  database = RailsAiContext::AppKind.mongoid_database(rails_app.root)
131
142
  db = "MongoDB through Mongoid#{" (database #{database})" if database}"
143
+ elsif !RailsAiContext::AppKind.active_record?(rails_app.root)
144
+ db = nil
132
145
  else
133
- db = "unknown"
146
+ db = RailsAiContext::SchemaAdapter.label(ctx)
134
147
  end
135
148
  rails = named_rails_version(ctx)
136
- lines << "#{ctx[:app_name]} is a Rails#{" #{rails}" if rails} application#{ruby_clause(ctx)} on #{db}."
149
+ lines << "#{ctx[:app_name]} is a Rails#{" #{rails}" if rails} application#{ruby_clause(ctx)} #{db ? "on #{db}" : "without Active Record"}."
150
+ unread = unread_bundle(rails)
151
+ lines << unread if unread
137
152
 
138
153
  notable = Payload.notable_gems(ctx)
139
154
  if notable.any?
@@ -190,64 +205,58 @@ module RailsAiContext
190
205
  end
191
206
 
192
207
  def section_auth(ctx)
193
- auth = Payload.section(ctx, :auth)
194
- lines = [ "## Authentication & Authorization", "" ]
195
- has_content = false
208
+ auth = Payload.section(ctx, :auth) || {}
209
+ gem_authn, gem_authz = auth_gem_lines(ctx)
210
+ # Each half falls back to the gems on its own: a policy directory says nothing about login.
211
+ authn = authentication_lines(auth).presence || gem_authn
212
+ authz = authorization_lines(auth).presence || gem_authz
213
+ found = authn + Introspectors::AuthIntrospector.digest_sentences(auth[:authentication]) + authz
214
+ return [] if found.empty?
215
+
216
+ [ "## Authentication & Authorization", "", *found, "" ]
217
+ end
196
218
 
197
- if auth
198
- authentication = auth[:authentication] || {}
199
- authorization = auth[:authorization] || {}
200
- if authentication[:method]
201
- lines << "Authentication is handled by #{authentication[:method]}."
202
- has_content = true
203
- end
204
- if authentication[:model]
205
- lines << "The #{authentication[:model]} model handles user accounts."
206
- has_content = true
207
- end
208
- if authorization[:method]
209
- lines << "Authorization uses #{authorization[:method]}."
210
- has_content = true
211
- end
219
+ # What the auth introspector writes, one sentence per finding.
220
+ def authentication_lines(auth)
221
+ authn = auth[:authentication] || {}
222
+ modules = auth[:devise_modules_per_model] || {}
223
+ lines = Array(authn[:devise]).map do |entry|
224
+ used = Array(modules[entry[:model]])
225
+ "Authentication via Devise on #{entry[:model]}#{" (#{used.join(', ')})" if used.any?}."
212
226
  end
213
-
214
- # Fallback: detect auth from gems if introspector didn't provide data
215
- unless has_content
216
- notable = Payload.notable_gems(ctx)
217
- if notable.any?
218
- auth_gem_names = %w[devise omniauth rodauth sorcery clearance authlogic]
219
- auth_gems = notable.select { |g| g.is_a?(Hash) && auth_gem_names.include?(g[:name].to_s) }
220
- if auth_gems.any?
221
- lines << "Authentication via #{auth_gems.map { |g| "#{g[:name]}#{g[:version] ? " (#{g[:version]})" : ""}" }.join(', ')}."
222
- has_content = true
223
- end
224
- authz_gem_names = %w[pundit cancancan action_policy rolify]
225
- authz_gems = notable.select { |g| g.is_a?(Hash) && authz_gem_names.include?(g[:name].to_s) }
226
- if authz_gems.any?
227
- lines << "Authorization via #{authz_gems.map { |g| g[:name] }.join(', ')}."
228
- has_content = true
229
- end
230
- end
227
+ lines << "Authentication via the Rails authentication generator (Session and Current models)." if authn[:rails_auth]
228
+ if (rodauth = authn[:rodauth])
229
+ classes = Array(rodauth[:classes])
230
+ lines << "Authentication via Rodauth#{" (#{classes.join(', ')})" if classes.any?}."
231
231
  end
232
+ lines << "has_secure_password on #{authn[:has_secure_password].join(', ')}." if Array(authn[:has_secure_password]).any?
233
+ lines << "OmniAuth providers: #{authn[:omniauth_providers].join(', ')}." if Array(authn[:omniauth_providers]).any?
234
+ lines
235
+ end
232
236
 
233
- # Fallback: detect from conventions (global before_actions like authenticate_user!)
234
- unless has_content
235
- conv = Payload.section(ctx, :conventions)
236
- if conv
237
- before_acts = Array(conv[:before_actions]).select { |a| a.to_s.match?(/authenticat|authorize/) }
238
- auth_checks = Array(conv[:authorization_checks]) + before_acts
239
- if auth_checks.any?
240
- lines << "Auth checks detected: #{auth_checks.first(5).join(', ')}."
241
- has_content = true
242
- end
243
- end
237
+ def authorization_lines(auth)
238
+ authz = auth[:authorization] || {}
239
+ lines = []
240
+ { pundit: "Pundit", action_policy: "Action Policy" }.each do |key, label|
241
+ lines << "Authorization via #{label} (#{count_phrase(authz[key].size, "policy")})." if Array(authz[key]).any?
244
242
  end
245
-
246
- return [] unless has_content
247
- lines << ""
243
+ lines << "#{count_phrase(authz[:policies].size, "policy class")} in app/policies." if Array(authz[:policies]).any?
244
+ lines << "Authorization via CanCanCan (app/models/ability.rb)." if authz[:cancancan]
245
+ lines << "An Ability class in #{authz[:ability_class]}." if authz[:ability_class]
248
246
  lines
249
247
  end
250
248
 
249
+ # [authentication lines, authorization lines] named from the notable gems alone.
250
+ def auth_gem_lines(ctx)
251
+ notable = Payload.notable_gems(ctx).select { |g| g.is_a?(Hash) }
252
+ authn = notable.select { |g| AUTH_GEMS.include?(g[:name].to_s) }
253
+ authz = notable.select { |g| AUTHZ_GEMS.include?(g[:name].to_s) }
254
+ [
255
+ authn.any? ? [ "Authentication via #{authn.map { |g| "#{g[:name]}#{" (#{g[:version]})" if g[:version]}" }.join(', ')}." ] : [],
256
+ authz.any? ? [ "Authorization via #{authz.map { |g| g[:name] }.join(', ')}." ] : []
257
+ ]
258
+ end
259
+
251
260
  def section_key_flows(ctx)
252
261
  routes = Payload.section(ctx, :routes)
253
262
  return [] unless routes
@@ -361,9 +370,7 @@ module RailsAiContext
361
370
  framework = tests[:framework] || "unknown"
362
371
  lines << "Framework: #{framework}."
363
372
 
364
- factories = tests[:factories]
365
- fixtures = tests[:fixtures]
366
- lines << "Data setup: #{factories ? "FactoryBot (#{factory_phrase(tests)})" : fixtures ? "fixtures (#{RailsAiContext::TestFramework.fixture_phrase(fixtures)})" : "inline"}."
373
+ lines << "Data setup: #{data_setup_phrase(tests)}." unless framework == RailsAiContext::TestFramework::NOT_READ
367
374
 
368
375
  ci = tests[:ci_config]
369
376
  lines << "CI: #{ci.join(', ')}." if ci&.any?
@@ -377,12 +384,32 @@ module RailsAiContext
377
384
  lines
378
385
  end
379
386
 
387
+ def data_setup_phrase(tests)
388
+ if tests[:factories]
389
+ "FactoryBot (#{factory_phrase(tests)})"
390
+ elsif tests[:fabricators]
391
+ "Fabrication (#{fabricator_phrase(tests)})"
392
+ elsif tests[:fixtures]
393
+ "fixtures (#{RailsAiContext::TestFramework.fixture_phrase(tests[:fixtures])})"
394
+ else
395
+ "inline"
396
+ end
397
+ end
398
+
399
+ def fabricator_phrase(tests)
400
+ files = count_phrase(tests[:fabricators][:count].to_i, "file")
401
+ names = tests[:fabricator_names]
402
+ return files unless names.is_a?(Hash) && names.any?
403
+
404
+ "#{count_phrase(names.values.sum { |list| Array(list).size }, "fabricator")} in #{files}"
405
+ end
406
+
380
407
  # The factories a suite defines, and the files they sit in: one file
381
408
  # commonly defines several, so the file count is not the factory count.
382
409
  def factory_phrase(tests)
383
- files = count_phrase(tests[:factories][:count].to_i, "file")
410
+ files = RailsAiContext::TestFramework.factory_files_phrase(tests[:factories])
384
411
  names = tests[:factory_names]
385
- return files unless names.is_a?(Hash) && names.any?
412
+ return files || "files not read" unless names.is_a?(Hash) && names.any?
386
413
 
387
414
  # A factory named in a loop defines at least one, so the count is a floor.
388
415
  computed = tests[:computed_factories].to_i
@@ -406,6 +433,54 @@ module RailsAiContext
406
433
  ].compact
407
434
  end
408
435
 
436
+ def section_rake_tasks(ctx, limit: RAKE_TASKS_SHOWN)
437
+ tasks = Payload.list(ctx, :rake_tasks, :tasks)
438
+ return [] if tasks.empty?
439
+
440
+ limit ||= tasks.size
441
+ lines = [ "## Rake Tasks", "" ]
442
+ tasks.first(limit).each { |task| lines << rake_task_line(task) }
443
+ lines << "...#{tasks.size - limit} more: `detail:\"full\"` lists every task." if tasks.size > limit
444
+ lines << ""
445
+ end
446
+
447
+ def section_all_rake_tasks(ctx)
448
+ section_rake_tasks(ctx, limit: nil)
449
+ end
450
+
451
+ def section_generators(ctx)
452
+ data = Payload.section(ctx, :rake_tasks) || {}
453
+ generators = Array(data[:generators])
454
+ templates = Array(data[:generator_templates])
455
+ railties = Array(data[:railties])
456
+ return [] if generators.empty? && templates.empty? && railties.empty?
457
+
458
+ lines = [ "## Generators and Railties", "" ]
459
+ generators.each do |g|
460
+ lines << "- `#{g[:command]}`#{" - #{g[:usage]}" if g[:usage]} (`#{g[:file]}`)"
461
+ end
462
+ templates.each do |t|
463
+ lines << "- `#{t[:file]}` replaces the template the `#{t[:generator]}` generator writes"
464
+ end
465
+ railties.each do |r|
466
+ line = "- Railtie `#{r[:name]}` (`#{r[:file]}`)"
467
+ notes = []
468
+ notes << "initializers #{r[:initializers].map { |i| "`#{i}`" }.join(', ')}" if Array(r[:initializers]).any?
469
+ notes << "adds rake tasks" if r[:rake_tasks]
470
+ lines << (notes.any? ? "#{line}: #{notes.join('; ')}" : line)
471
+ end
472
+ lines << ""
473
+ end
474
+
475
+ def rake_task_line(task)
476
+ return "- `#{task[:file]}`: not read (#{task[:error]})" if task[:error]
477
+
478
+ args = Array(task[:args])
479
+ line = "- `#{task[:name]}#{"[#{args.join(',')}]" if args.any?}`"
480
+ line += " - #{task[:description]}" if task[:description]
481
+ line + " (`#{task[:file]}`)"
482
+ end
483
+
409
484
  # ── Full-only sections ───────────────────────────────────────────
410
485
 
411
486
  def section_payments(ctx)
@@ -470,34 +545,29 @@ module RailsAiContext
470
545
  text = Payload.section(ctx, :action_text)
471
546
  return [] unless storage || text
472
547
 
473
- lines = [ "## File Storage & Rich Text", "" ]
548
+ lines = []
474
549
  if storage && storage[:attachments]&.any?
475
550
  lines << "Active Storage: #{count_phrase(storage[:attachments].size, "attachment")} across models."
476
551
  end
477
552
  if text && text[:models]&.any?
478
553
  lines << "Action Text: #{count_phrase(text[:models].size, "model")} with rich text fields."
479
554
  end
480
- lines << ""
481
- lines
555
+ return [] if lines.empty?
556
+
557
+ [ "## File Storage & Rich Text", "", *lines, "" ]
482
558
  end
483
559
 
484
560
  def section_api(ctx)
485
561
  api = Payload.section(ctx, :api)
486
562
  return [] unless api
487
- return [] if api.empty? || (api[:endpoints]&.empty? && api[:graphql].nil?)
488
563
 
489
- lines = [ "## API", "" ]
490
- if api[:graphql]
491
- lines << "GraphQL API detected."
492
- end
493
- if api[:endpoints]&.any?
494
- lines << "#{count_phrase(api[:endpoints].size, "API endpoint")}."
495
- end
496
- if api[:serializers]&.any?
497
- lines << "Serializers: #{api[:serializers].size}."
498
- end
499
- lines << ""
500
- lines
564
+ lines = []
565
+ lines << "GraphQL API detected." if api[:graphql]
566
+ serialization = GetApi.serialization_found(api)
567
+ lines << "Serialization: #{serialization}." if serialization
568
+ return [] if lines.empty?
569
+
570
+ [ "## API", "", *lines, "" ]
501
571
  end
502
572
 
503
573
  def section_devops(ctx)
@@ -506,9 +576,11 @@ module RailsAiContext
506
576
  has_content = false
507
577
 
508
578
  if devops
509
- lines << "Dockerfile: #{devops[:dockerfile] ? 'present' : 'not found'}."
510
- lines << "Procfile: #{devops[:procfile] ? 'present' : 'not found'}." if devops.key?(:procfile)
511
- deploy = devops[:deployment_method]
579
+ lines << "Dockerfile: #{devops[:docker] ? 'present' : 'not found'}."
580
+ procfiles = Array(devops[:procfile]).map { |procfile| procfile[:file] }
581
+ lines << "Procfile: #{procfiles.include?('Procfile') ? 'present' : 'not found'}."
582
+ lines << "Procfile.dev: present." if procfiles.include?("Procfile.dev")
583
+ deploy = devops[:deployment]
512
584
  lines << "Deployment: #{deploy}." if deploy
513
585
  has_content = true
514
586
  end
@@ -517,7 +589,7 @@ module RailsAiContext
517
589
  unless has_content
518
590
  root = rails_app.root.to_s
519
591
  has_dockerfile = File.exist?(File.join(root, "Dockerfile")) || File.exist?(File.join(root, "Dockerfile.dev"))
520
- has_procfile = File.exist?(File.join(root, "Procfile")) || File.exist?(File.join(root, "Procfile.dev"))
592
+ has_procfile = File.exist?(File.join(root, "Procfile"))
521
593
  has_ci = Dir.exist?(File.join(root, ".github", "workflows")) || File.exist?(File.join(root, ".gitlab-ci.yml"))
522
594
 
523
595
  if has_dockerfile || has_procfile || has_ci
@@ -551,7 +623,11 @@ module RailsAiContext
551
623
  mounted.each do |e|
552
624
  name = e[:engine]
553
625
  path = e[:path]
554
- lines << (path ? "- **#{name}** at `#{path}`" : "- **#{name}**") if name
626
+ next unless name
627
+
628
+ line = path ? "- **#{name}** at `#{path}`" : "- **#{name}**"
629
+ line += " (`#{e[:condition]}`)" if e[:condition]
630
+ lines << line
555
631
  end
556
632
  lines << ""
557
633
  lines
@@ -588,10 +664,20 @@ module RailsAiContext
588
664
  # is the interpreter running this tool, which says nothing about the
589
665
  # app. Then the sentence names no Ruby at all.
590
666
  def ruby_clause(ctx)
667
+ ruby = ruby_phrase(ctx)
668
+ return "" unless ruby
669
+
670
+ ctx[:tier].to_s == "static" ? " declaring #{ruby}" : " running #{ruby}"
671
+ end
672
+
673
+ # "Ruby 3.4.9", or the engine first ("JRuby 9.4.8.0 (Ruby 3.1.4)"):
674
+ # JRuby's own version is not the Ruby version it implements.
675
+ def ruby_phrase(ctx)
591
676
  version = named_ruby_version(ctx)
592
- return "" unless version
677
+ engine = ctx[:ruby_engine].to_s
678
+ return version && "Ruby #{version}" if engine.empty?
593
679
 
594
- ctx[:tier].to_s == "static" ? " declaring Ruby #{version}" : " running Ruby #{version}"
680
+ version ? "#{engine} (Ruby #{version})" : engine
595
681
  end
596
682
 
597
683
  # The version a sentence may name, or nil. Statically that is the one
@@ -66,24 +66,16 @@ module RailsAiContext
66
66
 
67
67
  lines = [ "# Performance Analysis", "" ]
68
68
 
69
- # Collect all items then filter, so the count reflects actual displayed results
70
- all_sections = CATEGORIES.transform_values { |(key, _, _)| data[key] || [] }
71
-
72
- # Apply model filter to count
73
- filtered_count = if model && !model.empty?
74
- all_sections.values.sum { |items| filter_items(items, model).size }
75
- elsif category != "all"
76
- (all_sections[category] || []).size
77
- else
78
- all_sections.values.sum(&:size)
79
- end
69
+ kept = CATEGORIES.select { |name, _| category == "all" || category == name }
70
+ .to_h { |name, (key, _, _)| [ name, filter_items(data[key], model) ] }
71
+ filtered_count = kept.values.sum(&:size)
80
72
 
81
73
  lines << "**Total issues found:** #{filtered_count}"
82
74
  lines << ""
83
75
 
84
76
  if RailsAiContext::DetailLevel.summary?(detail)
85
- CATEGORIES.each do |name, (_, label, _)|
86
- items = filter_items(all_sections[name], model)
77
+ kept.each do |name, items|
78
+ label = CATEGORIES[name][1]
87
79
  counts = name == "n_plus_one" ? risk_summary_counts(items) : ""
88
80
  lines << "- #{label}: #{items.size}#{counts}"
89
81
  end