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
@@ -43,6 +43,9 @@ module RailsAiContext
43
43
  key = fuzzy_find_key(models.keys, model) || model
44
44
  data = models[key]
45
45
  unless data
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?
48
+
46
49
  return not_found_response("Model", model, models.keys.sort,
47
50
  recovery_tool: "Call rails_get_model_details(detail:\"summary\") to see all models")
48
51
  end
@@ -131,6 +134,20 @@ module RailsAiContext
131
134
  lines
132
135
  end
133
136
 
137
+ private_class_method def self.sti_line(sti)
138
+ place = sti[:sti_parent] ? "subclass of `#{sti[:sti_parent]}`" : "base of #{sti[:sti_children].map { |c| "`#{c}`" }.join(', ')}"
139
+ "**STI:** #{place}#{", type column `#{sti[:type_column]}`" if sti[:type_column]}"
140
+ end
141
+
142
+ private_class_method def self.tenancy_line(tenancy)
143
+ file = tenancy[:declared_in]
144
+ case tenancy[:scope]
145
+ when "shared" then "**Tenancy:** shared schema: Apartment's `excluded_models` lists it (`#{file}`)"
146
+ when "per_tenant" then "**Tenancy:** one table in each tenant's schema: Apartment's `excluded_models` does not list it (`#{file}`)"
147
+ else "**Tenancy:** [INFERRED] Apartment computes `excluded_models` (`#{tenancy[:excluded_models]}` in `#{file}`), so whether this table is per tenant is not read"
148
+ end
149
+ end
150
+
134
151
  private_class_method def self.unavailable_row(name, data)
135
152
  Serializers::SectionFacts.unread_row("- **#{name}**", data)
136
153
  end
@@ -147,6 +164,12 @@ module RailsAiContext
147
164
  end
148
165
  lines = [ "# #{name}#{header_tag}", "" ]
149
166
  lines << "**Table:** `#{data[:table_name]}`" if data[:table_name]
167
+ if (database = data[:database])
168
+ lines << "**Database:** `#{database[:connects_to]}`#{" (only #{database[:condition]})" if database[:condition]}" \
169
+ "#{", inherited from `#{database[:declared_in]}`" if database[:declared_in]}"
170
+ end
171
+ lines << tenancy_line(data[:tenancy]) if data[:tenancy].is_a?(Hash)
172
+ lines << sti_line(data[:sti]) if data[:sti].is_a?(Hash) && (data[:sti][:sti_parent] || data[:sti][:sti_children])
150
173
  # A base class is not a concern, and the child may have no concerns at
151
174
  # all, so this stands outside that section.
152
175
  bases_unread = data[:bases_unread]
@@ -157,25 +180,38 @@ module RailsAiContext
157
180
  if structure
158
181
  lines << "**File:** `#{structure[:path]}` (#{count_phrase(structure[:total_lines], "line")})"
159
182
  map = structure[:sections].map { |s| "#{s[:label]}(#{s[:start]}-#{s[:end]})" }.join(" → ")
160
- lines << "**Structure:** #{map}"
183
+ lines << "**Structure:** #{map}" unless map.empty?
161
184
  end
162
185
 
163
186
  # Schema columns - inline from schema introspection
164
187
  if data[:table_name]
165
- schema = Payload.section(cached_context, :schema)
166
- if schema && schema[:tables]&.key?(data[:table_name])
167
- table_data = schema[:tables][data[:table_name]]
168
- cols = table_data[:columns] || []
188
+ (source, table_data), *others = Payload.model_tables(Payload.section(cached_context, :schema), data)
189
+ if table_data
190
+ ignored = Array(data[:ignored_columns])
191
+ cols = (table_data[:columns] || []).reject { |c| ignored.include?(c[:name].to_s) }
192
+ if (key = data[:primary_key] || table_data[:primary_key])
193
+ line = "**Primary key:** `#{Introspectors::SchemaConventions.primary_key_label(key)}`"
194
+ table_key = table_data[:primary_key]
195
+ line += " (the table's is `#{Introspectors::SchemaConventions.primary_key_label(table_key)}`)" if table_key && Array(table_key).map(&:to_s) != Array(key).map(&:to_s)
196
+ lines << line
197
+ end
169
198
  if cols.any?
170
199
  lines << "" << "## Columns"
171
200
  cols.each do |c|
172
- parts = [ "**#{c[:name]}**", c[:type] ]
201
+ parts = [ "**#{c[:name]}**", c[:type] ].compact
202
+ parts << "primary key" if c[:primary_key]
173
203
  parts << "NOT NULL" if c[:null] == false
174
204
  parts << "default: #{c[:default]}" if c[:default] && !c[:default].to_s.empty?
175
205
  parts << "array" if c[:array]
176
206
  lines << "- #{parts.join(' | ')}"
177
207
  end
208
+ differ = others.reject { |_, other| other[:columns] == table_data[:columns] }.map(&:first)
209
+ if differ.any?
210
+ lines << "" << "Columns of `#{data[:table_name]}` in #{source}; the table differs in #{differ.join(", ")}: " \
211
+ "`rails_get_schema(table:\"#{data[:table_name]}\")` lists each."
212
+ end
178
213
  end
214
+ lines << ignored_columns_line(ignored) if ignored.any?
179
215
  end
180
216
  end
181
217
 
@@ -186,10 +222,15 @@ module RailsAiContext
186
222
  lines << "" << "## Fields"
187
223
  data[:fields].each do |f|
188
224
  type_str = f[:type] ? ": #{f[:type]}" : ""
189
- lines << "- `#{f[:name]}`#{type_str}"
225
+ lines << "- `#{f[:name]}`#{type_str}#{", default: #{f[:default]}" if f.key?(:default)}"
190
226
  end
191
227
  end
192
228
 
229
+ if data[:indexes]&.any?
230
+ lines << "" << "## Indexes"
231
+ data[:indexes].each { |index| lines << "- `#{index}`" }
232
+ end
233
+
193
234
  # embeds_many and embeds_one are listed under Associations too.
194
235
  listed = Array(data[:associations]).map { |a| a[:name].to_s }
195
236
  embeds = Array(data[:embeds]).reject { |e| listed.include?(e[:name].to_s) }
@@ -213,6 +254,10 @@ module RailsAiContext
213
254
  detail += " [polymorphic]" if a[:polymorphic]
214
255
  detail += (a[:optional] == true ? " [optional]" : " [optional: #{a[:optional]}]") if a[:optional]
215
256
  detail += " dependent: #{a[:dependent]}" if a[:dependent]
257
+ detail += option_pairs(a[:declared_options])
258
+ detail += " (delegated types: #{a[:delegated_types].join(', ')})" if a[:delegated_types]&.any?
259
+ detail += " (delegated types: from `#{a[:delegated_types_source]}`, not read statically)" if a[:delegated_types_source]
260
+ detail += " extension methods: #{a[:extension_methods].join(', ')}" if a[:extension_methods]&.any?
216
261
  detail += " (fk: #{Introspectors::SchemaConventions.key_text(a[:foreign_key])})" if a[:foreign_key] && a[:type] == "belongs_to"
217
262
  detail += " [UNAVAILABLE: #{a[:unavailable]}]" if a[:unavailable]
218
263
  lines << detail
@@ -272,26 +317,32 @@ module RailsAiContext
272
317
  end
273
318
 
274
319
  # Custom validate methods (business rules) - show method body when possible
275
- if data[:custom_validates]&.any?
320
+ if data[:custom_validates]&.any? || data[:custom_validate_blocks]&.any?
276
321
  lines << "" << "## Validations" unless data[:validations]&.any?
277
- bodies = extract_custom_validate_bodies(name, data[:custom_validates])
322
+ custom = Array(data[:custom_validates])
323
+ bodies = custom.any? ? extract_custom_validate_bodies(name, custom) : {}
278
324
  conditions = data[:custom_validate_conditions] || {}
279
- data[:custom_validates].each do |v|
325
+ custom.each do |v|
280
326
  tail = callback_condition_tail(conditions[v.to_s] || conditions[v.to_sym])
281
327
  lines << "- **Custom:** `#{v}`#{tail}#{" → #{bodies[v]}" if bodies[v]}"
282
328
  end
329
+ Array(data[:custom_validate_blocks]).each do |b|
330
+ lines << "- **Custom:** block#{callback_condition_tail(b[:conditions])}#{" → #{b[:body]}" unless b[:body].to_s.empty?}"
331
+ end
283
332
  end
284
333
 
285
334
  # Enums
286
335
  if data[:enums]&.any?
287
336
  lines << "" << "## Enums"
337
+ enum_options = data[:enum_options] || {}
288
338
  data[:enums].each do |attr, values|
339
+ options = enum_options[attr.to_s] || enum_options[attr.to_sym] || {}
289
340
  if values.is_a?(Hash)
290
341
  backing = values.values.first.is_a?(Integer) ? "integer" : "string"
291
342
  entries = values.map { |k, v| "#{k}(#{v})" }.join(", ")
292
- lines << "- `#{attr}`: #{entries} [#{backing}]"
343
+ lines << "- `#{attr}`: #{entries} [#{backing}]#{enum_options_text(attr, values.keys, options)}"
293
344
  else
294
- lines << "- `#{attr}`: #{Serializers::SectionFacts.enum_values(values)}"
345
+ lines << "- `#{attr}`: #{Serializers::SectionFacts.enum_values(values)}#{enum_options_text(attr, nil, options)}"
295
346
  end
296
347
  end
297
348
  end
@@ -299,9 +350,13 @@ module RailsAiContext
299
350
  # Scopes - show lambda body so AI can chain correctly. Each carries
300
351
  # the AST confidence: [VERIFIED] literal bodies vs [INFERRED] dynamic
301
352
  # expressions the parser can't fully resolve.
302
- if data[:scopes]&.any?
353
+ if data[:scopes]&.any? || data[:default_scopes]&.any?
303
354
  lines << "" << "## Scopes"
304
- data[:scopes].each do |s|
355
+ Array(data[:default_scopes]).each do |s|
356
+ tail = s[:all_queries] ? " (all_queries: #{s[:all_queries]})" : ""
357
+ lines << "- `default_scope` → #{s[:body] || Confidence::INFERRED}#{tail} _(applies to every query on #{name})_"
358
+ end
359
+ Array(data[:scopes]).each do |s|
305
360
  if s.is_a?(Hash)
306
361
  tag = s[:confidence] ? " #{s[:confidence]}" : ""
307
362
  lines << "- `#{s[:name]}` → #{s[:body]}#{tag}"
@@ -322,16 +377,28 @@ module RailsAiContext
322
377
  end
323
378
  lines << "- `#{callback_type_label(type)}`: #{targets.join(', ')}"
324
379
  end
380
+ lines << "" << commit_order_note([ name ]) if data[:commit_order_unread]
325
381
  end
326
382
 
327
383
  # Macros - surface hidden introspector data
328
384
  macro_lines = []
329
- macro_lines << "- `has_secure_password`" if data[:has_secure_password]
385
+ if data[:secure_passwords]&.any?
386
+ data[:secure_passwords].each { |pw| macro_lines << "- `has_secure_password` :#{pw[:attribute]}#{option_pairs(pw[:options])}" }
387
+ elsif data[:has_secure_password]
388
+ macro_lines << "- `has_secure_password`"
389
+ end
390
+ macro_lines << "- `has_secure_token` #{data[:has_secure_token].map { |f| ":#{f}" }.join(', ')}" if data[:has_secure_token]&.any?
391
+ Array(data[:nested_attributes]).each do |nested|
392
+ macro_lines << "- `accepts_nested_attributes_for` #{nested[:names].map { |n| ":#{n}" }.join(', ')}#{option_pairs(nested[:options])}"
393
+ end
330
394
  macro_lines << "- `encrypts` #{data[:encrypts].map { |f| ":#{f}" }.join(', ')}" if data[:encrypts]&.any?
331
395
  macro_lines << "- `normalizes` #{data[:normalizes].map { |f| ":#{f}" }.join(', ')}" if data[:normalizes]&.any?
332
396
  macro_lines << "- `generates_token_for` #{data[:generates_token_for].map { |f| ":#{f}" }.join(', ')}" if data[:generates_token_for]&.any?
333
- macro_lines << "- `serialize` #{data[:serialize].map { |f| ":#{f}" }.join(', ')}" if data[:serialize]&.any?
334
- macro_lines << "- `store` #{data[:store].map { |f| ":#{f}" }.join(', ')}" if data[:store]&.any?
397
+ if data[:serialize]&.any?
398
+ serialized = data[:serialize].map { |f| ":#{f}#{option_pairs(data[:serialize_options]&.dig(f))}" }
399
+ macro_lines << "- `serialize` #{serialized.join(', ')}"
400
+ end
401
+ macro_lines << "- `store` #{data[:store].map { |f| store_column_text(f, data[:store_accessors]) }.join(', ')}" if data[:store]&.any?
335
402
  macro_lines << "- `broadcasts` #{data[:broadcasts].join(', ')}" if data[:broadcasts]&.any?
336
403
  if data[:has_one_attached]&.any?
337
404
  macro_lines << "- `has_one_attached` #{data[:has_one_attached].map { |f| ":#{f}" }.join(', ')}"
@@ -339,11 +406,35 @@ module RailsAiContext
339
406
  if data[:has_many_attached]&.any?
340
407
  macro_lines << "- `has_many_attached` #{data[:has_many_attached].map { |f| ":#{f}" }.join(', ')}"
341
408
  end
409
+ macro_lines << "- `has_rich_text` #{data[:has_rich_text].map { |f| ":#{f}" }.join(', ')}" if data[:has_rich_text]&.any?
410
+ if data[:attributes]&.any?
411
+ macro_lines << "- `attribute` #{data[:attributes].map { |a| attribute_api_text(a) }.join(', ')}"
412
+ end
413
+ if data[:alias_attributes]&.any?
414
+ macro_lines << "- `alias_attribute` #{data[:alias_attributes].map { |a| ":#{a[:name]} → :#{a[:target]}" }.join(', ')}"
415
+ end
416
+ (data[:model_settings] || {}).each { |setting, value| macro_lines << "- `self.#{setting} = #{value}`" }
417
+ macro_lines << "- `attr_readonly` #{data[:attr_readonly].map { |f| ":#{f}" }.join(', ')}" if data[:attr_readonly]&.any?
418
+ macro_lines << "- `query_constraints` #{data[:query_constraints].map { |f| ":#{f}" }.join(', ')}" if data[:query_constraints]&.any?
419
+ Array(data[:gem_macros]).each do |gem_macro|
420
+ adds = Array(gem_macro[:adds]).map { |name| "`#{name}`" }
421
+ macro_lines << "- `#{gem_macro[:text]}`#{" (adds #{adds.join(', ')})" if adds.any?}"
422
+ end
342
423
  if macro_lines.any?
343
424
  lines << "" << "## Macros"
344
425
  lines.concat(macro_lines)
345
426
  end
346
427
 
428
+ Array(data[:state_machines]).each do |machine|
429
+ lines << "" << "## State machine (aasm, column `#{machine[:column]}`)"
430
+ states = machine[:states].map { |state| "`#{state}`#{" (initial)" if state == machine[:initial]}" }
431
+ lines << "- states: #{states.join(', ')}" if states.any?
432
+ machine[:events].each do |event|
433
+ moves = event[:transitions].map { |t| "#{t[:from].any? ? t[:from].join(' | ') : 'any'} -> #{t[:to]}" }
434
+ lines << "- event `#{event[:name]}`#{": #{moves.join(', ')}" if moves.any?}"
435
+ end
436
+ end
437
+
347
438
  # Encryption details (expanded from encrypts)
348
439
  if data[:encryption_details]&.any?
349
440
  lines << "" << "## Encryption Details"
@@ -364,7 +455,7 @@ module RailsAiContext
364
455
  if data[:token_generation]&.any?
365
456
  lines << "" << "## Token Generation"
366
457
  data[:token_generation].each do |tg|
367
- detail_str = tg.is_a?(Hash) ? "**#{tg[:purpose]}** (expires_in: #{tg[:expires_in] || 'default'})" : tg.to_s
458
+ detail_str = tg.is_a?(Hash) ? "**#{tg[:purpose]}** (expires_in: #{tg[:expires_in] || 'never'})" : tg.to_s
368
459
  lines << "- #{detail_str}"
369
460
  end
370
461
  end
@@ -373,7 +464,11 @@ module RailsAiContext
373
464
  if data[:delegations]&.any?
374
465
  lines << "" << "## Delegations"
375
466
  data[:delegations].each do |d|
376
- lines << "- delegate #{d[:methods].map { |m| ":#{m}" }.join(', ')} to: :#{d[:to]}"
467
+ line = "- delegate #{d[:methods].map { |m| ":#{m}" }.join(', ')} to: :#{d[:to]}"
468
+ line += " → #{d[:defines].join(', ')}" if d[:defines]
469
+ line += " → #{RailsAiContext::Confidence::INFERRED} (the prefix is computed)" if d[:prefix_computed]
470
+ line += " (private)" if d[:private]
471
+ lines << line
377
472
  end
378
473
  end
379
474
  lines << "- `delegate_missing_to` :#{data[:delegate_missing_to]}" if data[:delegate_missing_to]
@@ -409,9 +504,9 @@ module RailsAiContext
409
504
  lines << "_#{count_phrase(hidden, "concern")} hidden by `excluded_concerns`._" if hidden.positive?
410
505
  end
411
506
 
412
- # Declared only under a condition the source does not decide, or by a
413
- # called method on another receiver: named, and left out of every count above.
414
- { conditional_declarations: "Only under a condition the source does not decide",
507
+ # A declaration whose method name or condition the source does not decide, or one
508
+ # a called method makes on another receiver: named, and left out of every count above.
509
+ { conditional_declarations: "Not decided from the source (the method name or a condition)",
415
510
  foreign_declarations: "Declared on another class" }.each do |key, heading|
416
511
  next unless data[key]&.any?
417
512
 
@@ -455,6 +550,14 @@ module RailsAiContext
455
550
  end
456
551
  end
457
552
 
553
+ async = Array(Payload.section(cached_context, :jobs)&.dig(:async_methods)).select { |m| m[:owner] == name }
554
+ if async.any?
555
+ lines << "" << "## Runs in the background (delayed_job)"
556
+ async.each do |m|
557
+ lines << "- `#{m[:method]}`#{" [#{m[:options]}]" if m[:options]}: every call is queued by `handle_asynchronously` (`#{m[:file]}`)"
558
+ end
559
+ end
560
+
458
561
  # Cross-reference hints - guide AI to related tools
459
562
  hints = []
460
563
  hints << "`rails_get_schema(table:\"#{data[:table_name]}\")` for columns/indexes" if data[:table_name]
@@ -492,6 +595,44 @@ module RailsAiContext
492
595
  pairs.any? ? "**#{ed[:field]}** (#{pairs.join(', ')})" : "**#{ed[:field]}**"
493
596
  end
494
597
 
598
+ # Rails names each value's methods "#{prefix}#{label}#{suffix}", so the
599
+ # bare label is wrong once either is set.
600
+ private_class_method def self.enum_options_text(attr, labels, options)
601
+ text = options[:default].nil? ? "" : " default: #{options[:default]}"
602
+ prefix, suffix = options.values_at(:prefix, :suffix)
603
+ return text unless prefix || suffix
604
+ if [ prefix, suffix ].include?(Confidence::INFERRED)
605
+ return "#{text} methods: #{Confidence::INFERRED} (the prefix or suffix is computed)"
606
+ end
607
+ return "#{text} methods: #{Confidence::INFERRED} (the values are computed)" unless labels
608
+
609
+ prefix = "#{prefix == true ? attr : prefix}_" if prefix
610
+ suffix = "_#{suffix == true ? attr : suffix}" if suffix
611
+ methods = labels.map { |label| "#{prefix}#{label.to_s.gsub(/[\W&&[:ascii:]]+/, '_')}#{suffix}?" }
612
+ "#{text} methods: #{methods.join(', ')}"
613
+ end
614
+
615
+ private_class_method def self.ignored_columns_line(ignored)
616
+ inferred = RailsAiContext::Confidence::INFERRED
617
+ return "\n**Ignored columns:** #{inferred} (computed in the source)" if ignored.include?(inferred)
618
+
619
+ "\n**Ignored columns:** #{ignored.map { |c| "`#{c}`" }.join(', ')} _(the model cannot read or write them)_"
620
+ end
621
+
622
+ private_class_method def self.store_column_text(column, accessors)
623
+ names = Array(accessors&.dig(column))
624
+ return ":#{column}" if names.empty?
625
+
626
+ inferred = RailsAiContext::Confidence::INFERRED
627
+ names = names.map { |n| n == inferred ? "#{inferred}: the prefix or suffix is computed" : n }
628
+ ":#{column} (#{names.join(', ')})"
629
+ end
630
+
631
+ private_class_method def self.attribute_api_text(attribute)
632
+ facts = [ attribute[:type], ("default: #{attribute[:default]}" if attribute[:default]) ].compact
633
+ ":#{attribute[:name]}#{" (#{facts.join(', ')})" if facts.any?}"
634
+ end
635
+
495
636
  # A transformation the parser could not resolve is a marker, not the
496
637
  # name of a transformation, so it never follows the dash.
497
638
  private_class_method def self.normalization_line(nd)
@@ -501,7 +642,11 @@ module RailsAiContext
501
642
  return "**#{nd[:field]}** #{RailsAiContext::Confidence::INFERRED}" if transformation.nil? ||
502
643
  transformation == RailsAiContext::Confidence::INFERRED
503
644
 
504
- "**#{nd[:field]}** - #{transformation}"
645
+ "**#{nd[:field]}** - #{transformation}#{option_pairs(nd[:options])}"
646
+ end
647
+
648
+ private_class_method def self.option_pairs(options)
649
+ options&.any? ? " (#{options.map { |k, v| "#{k}: #{option_text(v)}" }.join(', ')})" : ""
505
650
  end
506
651
 
507
652
  # Extract bodies of custom validate methods (single-line or first meaningful line)
@@ -534,7 +679,9 @@ module RailsAiContext
534
679
 
535
680
  private_class_method def self.extract_method_signatures(model_name)
536
681
  source = model_source(model_name) or return nil
537
- Introspectors::ActionResolver.public_methods_from_source(source, owner: model_name)
682
+ # A def carries an end line; delegate and attr_* names do not, and they lead a model, so they go after the cap's defs.
683
+ written, generated = Introspectors::ActionResolver.public_methods_in(source, owner: model_name).partition { |m| m[:end_location] }
684
+ (written + generated).map { |m| Introspectors::ActionResolver.signature(m) }.uniq
538
685
  end
539
686
 
540
687
  # On the booted tier reflection has already answered associations,
@@ -588,13 +735,18 @@ module RailsAiContext
588
735
  source = RailsAiContext::SafeFile.read(full_path) or return nil
589
736
 
590
737
  source_lines = source.lines
738
+ # A def's kind comes from the walk: a regex cannot see `class << self`,
739
+ # `def Name.x`, or a def that only extends a scope.
740
+ def_scopes = Introspectors::ActionResolver.methods_in(source).to_h { |m| [ m[:location], m[:scope] ] }
591
741
  sections = []
592
742
  current_section = nil
593
743
  current_start = nil
594
744
 
595
745
  source_lines.each_with_index do |line, idx|
746
+ # Anything the listeners cannot scope: a section is a run of lines, which no node carries.
596
747
  label = case line
597
- when /\A\s*class\s/ then "class definition"
748
+ when /\A\s*(?:[a-z_]+\s+)*def\s/ then { class: "class methods", instance: "instance methods" }[def_scopes[idx + 1]]
749
+ when /\A\s*class\s+(?!<<)/, /\A\s*[A-Z][\w:]*\s*=\s*Class\.new\b/ then "class definition"
598
750
  when /\A\s*(include|extend|prepend)\s/ then "includes"
599
751
  when /\A\s*[A-Z_]+\s*=/ then "constants"
600
752
  when /\A\s*(belongs_to|has_many|has_one|has_and_belongs_to_many)\s/ then "associations"
@@ -602,13 +754,11 @@ module RailsAiContext
602
754
  when /\A\s*scope\s/ then "scopes"
603
755
  when /\A\s*(enum|encrypts|normalizes|has_secure_password|has_one_attached|has_many_attached)\s/ then "macros"
604
756
  when /\A\s*(before_|after_|around_)/ then "callbacks"
605
- when /\A\s*def\s+self\./ then "class methods"
606
- when /\A\s*def\s/ then "instance methods"
607
757
  when /\A\s*private\s*$/ then "private"
608
758
  end
609
759
 
610
760
  if label && label != current_section
611
- sections << { start: current_start, end: idx + 1, label: current_section } if current_section
761
+ sections << { start: current_start, end: idx, label: current_section } if current_section
612
762
  current_section = label
613
763
  current_start = idx + 1
614
764
  end