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
@@ -52,7 +52,7 @@ module RailsAiContext
52
52
 
53
53
  unloadable_models.each { |class_name, details| result[class_name] ||= details }
54
54
 
55
- result
55
+ tag_tenancy(result)
56
56
  end
57
57
 
58
58
  # Models the static scan finds, abstract bases left out. excluded_models is the app's
@@ -80,7 +80,7 @@ module RailsAiContext
80
80
  candidates = static_candidates
81
81
  sti_parents = candidates.keys.to_h { |name| [ name, sti_parent(name, candidates, []) ] }
82
82
  bases = declared_bases(candidates)
83
- candidates.each_with_object({}) do |(class_name, candidate), result|
83
+ models = candidates.each_with_object({}) do |(class_name, candidate), result|
84
84
  # Hidden from the listing, kept in the walk: its children still
85
85
  # inherit its table and its declarations.
86
86
  next if config.excluded_models.include?(class_name)
@@ -112,6 +112,7 @@ module RailsAiContext
112
112
  table = resolve_table_name(class_name, candidates)
113
113
  result[class_name] = static_model_details(candidate[:path], class_name, file: candidate[:file],
114
114
  table_name: table,
115
+ primary_key: resolve_primary_key(class_name, candidates),
115
116
  inherited_from: declaring_bases(class_name, candidates),
116
117
  sti: static_sti_info(class_name, sti_parents),
117
118
  parent_model: sti_parents[class_name])
@@ -123,14 +124,38 @@ module RailsAiContext
123
124
  # not have.
124
125
  result[class_name] = { error: e.message, file: candidate[:file], table_name: table }.compact
125
126
  end
127
+ tag_tenancy(models)
126
128
  end
127
129
 
128
130
  private
129
131
 
132
+ # Apartment keeps an excluded model, and an STI subclass sharing its table, in the
133
+ # shared schema; every other ActiveRecord model has a table in each tenant's schema.
134
+ def tag_tenancy(models)
135
+ config = ApartmentConfig.read(app.root) or return models
136
+ excluded = config[:excluded_models]
137
+ models.each do |name, data|
138
+ next if data[:error] || data[:mongoid]
139
+
140
+ scope = if excluded.nil? then "unknown"
141
+ elsif apartment_shared?(name, models, excluded) then "shared"
142
+ else "per_tenant"
143
+ end
144
+ data[:tenancy] = { gem: "apartment", scope: scope, declared_in: config[:file], excluded_models: config[:excluded_models_source] }.compact
145
+ end
146
+ end
147
+
148
+ def apartment_shared?(name, models, excluded, seen = [])
149
+ return true if excluded.include?(name)
150
+ return false if seen.include?(name)
151
+
152
+ parent = models.dig(name, :sti, :sti_parent)
153
+ parent ? apartment_shared?(parent, models, excluded, seen + [ name ]) : false
154
+ end
155
+
130
156
  # The same shape the booted tier reports under :sti, off the chain the
131
- # static tier already resolves to share the base's table. A model that
132
- # inherits from another model IS the STI relation, so no type column has
133
- # to be read to name it.
157
+ # static tier already resolves to share the base's table. The caller
158
+ # drops it when the schema shows that table has no type column.
134
159
  def static_sti_info(class_name, sti_parents)
135
160
  parent = sti_parents[class_name]
136
161
  children = sti_parents.select { |_name, other_parent| other_parent == class_name }.keys.sort
@@ -144,6 +169,18 @@ module RailsAiContext
144
169
  }.compact
145
170
  end
146
171
 
172
+ # Rails runs no STI on a table without the type column; a table the dump does not hold is not known to lack it.
173
+ def lacks_column?(table, column)
174
+ reader = SchemaReader.for(app.root)
175
+ reader.tables.key?(table) && column != RailsAiContext::Confidence::INFERRED && !reader.column?(table, column)
176
+ end
177
+
178
+ # Rails' default column, the literal a class sets, or nil where `inheritance_column = nil` turns STI off.
179
+ def static_type_column(source_data)
180
+ setting = Array(source_data[:macros]).select { |m| m[:macro] == :model_setting && m[:setting] == "inheritance_column" }.last
181
+ setting ? setting[:literal].presence : "type"
182
+ end
183
+
147
184
  # A model file the app cannot load leaves its class out of reflection,
148
185
  # and leaving it out here answers that the model does not exist and that
149
186
  # its table has no model at all. The file is a fact the source walk
@@ -155,6 +192,9 @@ module RailsAiContext
155
192
  candidates = static_candidates
156
193
  @unloadable.each_with_object({}) do |(class_name, error), entries|
157
194
  candidate = candidates[class_name]
195
+ # Other roots hold generators and gem subclasses too; static lists one only as a model.
196
+ next if @outside_model_dirs&.include?(class_name) && !static_model?(class_name, candidates)
197
+
158
198
  entries[class_name] = {
159
199
  error: error,
160
200
  file: candidate&.dig(:file),
@@ -172,7 +212,7 @@ module RailsAiContext
172
212
  # process cannot stat is an error entry rather than one that quietly
173
213
  # is not there. The count is an answer too.
174
214
  def static_candidates
175
- SourceScan.paths(app.root, kind: "app/models", skip_concerns: false).each_with_object({}) do |record, found|
215
+ SourceScan.model_paths(app.root).each_with_object({}) do |record, found|
176
216
  begin
177
217
  source = model_source(record.path) if File.size(record.path) <= RailsAiContext.configuration.max_file_size
178
218
  if source.nil?
@@ -182,7 +222,7 @@ module RailsAiContext
182
222
 
183
223
  next if mixin_path?(record.path_name.underscore, source)
184
224
 
185
- declarations = DeclaredConstant.declarations(source)
225
+ declarations = DeclaredConstant.declarations(source, path_name: record.path_name)
186
226
  declaration = DeclaredConstant.declaration_for(declarations, record.path_name)
187
227
  class_name = declaration&.name || record.path_name
188
228
  # Two files can declare one class (an app reopening a model to add methods);
@@ -197,6 +237,7 @@ module RailsAiContext
197
237
  path: record.path,
198
238
  file: record.file,
199
239
  superclass: declaration&.superclass,
240
+ superclass_nesting: declaration&.nesting,
200
241
  abstract: abstract_class?(source)
201
242
  }.merge(TableName.declarations(source, class_name, app.root))
202
243
  rescue => e
@@ -244,6 +285,10 @@ module RailsAiContext
244
285
  end
245
286
  end
246
287
 
288
+ def static_model?(class_name, candidates)
289
+ candidates.key?(class_name) && !candidates.dig(class_name, :abstract) && model_class?(class_name, candidates)
290
+ end
291
+
247
292
  def model_class?(class_name, candidates, seen = [])
248
293
  return false if seen.include?(class_name)
249
294
  # A base under app/models that nobody can read is taken at its word:
@@ -271,7 +316,8 @@ module RailsAiContext
271
316
  # so `Admin::Report < Post` means the top-level Post unless Admin
272
317
  # declares one.
273
318
  def resolve_superclass(name, from, candidates)
274
- SuperclassChain.resolve_in_scope(from, name) { |qualified| qualified if candidates.key?(qualified) }
319
+ nesting = candidates.dig(from, :superclass_nesting)
320
+ SuperclassChain.resolve_in_scope(from, name, nesting: nesting) { |qualified| qualified if candidates.key?(qualified) }
275
321
  end
276
322
 
277
323
  # Rails' own order: what the class assigns itself wins, an STI child
@@ -290,10 +336,43 @@ module RailsAiContext
290
336
 
291
337
  [ namespace_affix(class_name, candidates, :table_name_prefix),
292
338
  contained_prefix(class_name, candidates, seen),
293
- TableName.stem(candidate[:path]),
339
+ TableName.stem(candidate[:path], pluralize_tables?(class_name, candidates)),
294
340
  namespace_affix(class_name, candidates, :table_name_suffix) ].join
295
341
  end
296
342
 
343
+ # A key the class does not assign is read through a new connection, which an
344
+ # unreachable host can hold for a full connect timeout per model; unconnected, read the source.
345
+ def model_primary_key(model)
346
+ return model.primary_key if model.connected?
347
+
348
+ base = model.respond_to?(:base_class) ? model.base_class : model
349
+ path = model_source_path(base)
350
+ path && TableName.declarations(model_source(path), base.name, app.root)[:primary_key]
351
+ rescue StandardError => e
352
+ RailsAiContext.debug_fail(e, nil, label: "model_primary_key")
353
+ end
354
+
355
+ # Rails reads an STI child's key off its base class; an abstract base's assignment reaches no model.
356
+ def resolve_primary_key(class_name, candidates, seen = [])
357
+ own = candidates.dig(class_name, :primary_key)
358
+ return own if own
359
+
360
+ parent = sti_parent(class_name, candidates, seen)
361
+ parent && resolve_primary_key(parent, candidates, seen + [ class_name ])
362
+ end
363
+
364
+ # A class attribute: the class's own assignment, else its superclass chain's, else the app's.
365
+ def pluralize_tables?(class_name, candidates, seen = [])
366
+ own = candidates.dig(class_name, :pluralize_table_names)
367
+ return own unless own.nil?
368
+
369
+ parent = candidates.dig(class_name, :superclass)
370
+ resolved = parent && !seen.include?(class_name) && resolve_superclass(parent, class_name, candidates)
371
+ return pluralize_tables?(resolved, candidates, seen + [ class_name ]) if resolved && candidates.key?(resolved)
372
+
373
+ ActiveRecordSettings.for(app.root)[:pluralize_table_names] != false
374
+ end
375
+
297
376
  # compute_table_name (7.0 and 8.1): a class nested in a concrete model
298
377
  # takes that model's singular table as a prefix (Project::Phase, project_phases).
299
378
  def contained_prefix(class_name, candidates, seen)
@@ -302,7 +381,9 @@ module RailsAiContext
302
381
  return "" unless model_class?(parent, candidates)
303
382
 
304
383
  table = resolve_table_name(parent, candidates, seen + [ class_name ])
305
- table.to_s.empty? ? "" : "#{table.singularize}_"
384
+ return "" if table.to_s.empty?
385
+
386
+ "#{pluralize_tables?(parent, candidates) ? table.singularize : table}_"
306
387
  end
307
388
 
308
389
  # Every class this one inherits declarations from: the superclass chain
@@ -383,7 +464,7 @@ module RailsAiContext
383
464
 
384
465
  scope.pop
385
466
  end
386
- candidates.dig(class_name, key) || TableName.app_affixes(app.root)[key] || ""
467
+ candidates.dig(class_name, key) || ActiveRecordSettings.for(app.root)[key] || ""
387
468
  end
388
469
 
389
470
  def isolated_prefixes
@@ -411,7 +492,7 @@ module RailsAiContext
411
492
  return false unless segments.include?("concerns")
412
493
  return true if segments.first == "concerns"
413
494
 
414
- !DeclaredConstant.declares_class?(source)
495
+ !DeclaredConstant.declares_class?(source, path_name: relative.camelize)
415
496
  end
416
497
 
417
498
  # A class whose file is inside an installed gem (PaperTrail::Version) is
@@ -426,24 +507,13 @@ module RailsAiContext
426
507
  location = Object.const_source_location(model.name)&.first
427
508
  return false unless location
428
509
 
429
- root = "#{app.root}#{File::SEPARATOR}"
430
- Gem.loaded_specs.each_value.any? do |spec|
431
- dir = "#{spec.full_gem_path}#{File::SEPARATOR}"
432
- location.start_with?(dir) && !app_owned_gem?(spec, dir, root)
433
- end
510
+ PortablePath.gem_file?(location, app.root)
434
511
  rescue NameError, ArgumentError, TypeError
435
512
  false
436
513
  end
437
514
 
438
- # A `path:` gem kept in the repo, or a gem the app sits inside (an
439
- # engine's dummy app). A bundle installed under the root (vendor/bundle)
440
- # is still the gems' own.
441
- def app_owned_gem?(spec, dir, root)
442
- return true if root.start_with?(dir)
443
- return false unless dir.start_with?(root) && defined?(Bundler::Source::Path)
444
-
445
- source = spec.respond_to?(:source) ? spec.source : nil
446
- source.is_a?(Bundler::Source::Path) && !source.is_a?(Bundler::Source::Git)
515
+ def model_dir_file?(path)
516
+ PathResolver.model_dirs(app.root).any? { |dir| SafePath.contained?(path, PathResolver.root_key(dir)) }
447
517
  end
448
518
 
449
519
  def discover_models
@@ -458,12 +528,13 @@ module RailsAiContext
458
528
  end
459
529
 
460
530
  known = models.map(&:name).to_set
531
+ @outside_model_dirs = Set.new
461
532
  # Concerns stay in: a nested concerns directory is a namespace, so a
462
533
  # class declared under one is a model and constantize sorts the mixins
463
534
  # out. A top-level `app/models/concerns` is an autoload root instead,
464
535
  # so its files declare no `Concerns::` prefix and that path name never
465
536
  # constantizes.
466
- SourceScan.paths(app.root, kind: "app/models", skip_concerns: false).each do |record|
537
+ SourceScan.model_paths(app.root, base_model: model_base_check).each do |record|
467
538
  next if record.path_name.start_with?("Concerns::")
468
539
  next if known.include?(record.path_name)
469
540
  next if config.excluded_models.include?(record.path_name)
@@ -471,9 +542,10 @@ module RailsAiContext
471
542
  # The path does not name the class: an app inflection only changes
472
543
  # case, so activitypub/activity.rb camelizes to a constant the app
473
544
  # does not have and the file was listed as a model that will not
474
- # load. Read only where the camelized name is not already loaded, so
545
+ # load. A root pushed under a namespace declares a prefix the path
546
+ # lacks. Read only where the camelized name is not already loaded, so
475
547
  # a booted run does not parse every model file to learn nothing.
476
- class_name = DeclaredConstant.resolve(model_source(record.path).to_s, record.path_name)
548
+ class_name = DeclaredConstant.named(model_source(record.path).to_s, record.path_name)
477
549
  next if known.include?(class_name)
478
550
  next if config.excluded_models.include?(class_name)
479
551
 
@@ -487,6 +559,7 @@ module RailsAiContext
487
559
  # its name is recorded: a file that exists for a class reflection
488
560
  # lacks is not the same answer as no such model.
489
561
  @unloadable[class_name] = e.message.to_s.lines.first.to_s.strip
562
+ @outside_model_dirs << class_name unless model_dir_file?(record.path)
490
563
  end
491
564
  end
492
565
 
@@ -501,9 +574,10 @@ module RailsAiContext
501
574
  # and the superclasses are merged here too, or the static tier
502
575
  # out-answers this one.
503
576
  bases = booted_declaring_bases(model)
504
- calls = class_calls([ model_source_path(model), *bases.map(&:last) ])
577
+ calls = singleton_lookup([ [ model.name, model_source_path(model) ], *bases ])
505
578
  source_data, unread, bases_unread, hidden =
506
- merge_class_and_bases(own_source, model.name, calls, bases, file: model_source_path(model))
579
+ merge_class_and_bases(own_source, model.name, calls, bases, file: model_source_path(model),
580
+ commits_in_order: booted_commits_in_order)
507
581
 
508
582
  class_methods = extract_class_methods_from_ast(model, source_data)
509
583
  instance_methods = extract_instance_methods_from_ast(model, source_data)
@@ -512,12 +586,14 @@ module RailsAiContext
512
586
  # reflection's own list is inflated by an attribute method per column
513
587
  # as soon as anything instantiates the model.
514
588
  source_instance_methods = own_source_methods(model, source_data)
589
+ concerns = booted_concerns(model, source_data[:mixins])
515
590
 
516
591
  details = {
517
592
  table_name: model.table_name,
593
+ primary_key: model_primary_key(model),
518
594
  file: relative_to_root(model_source_path(model)),
519
595
  # Reflection-based (runtime, most accurate for these)
520
- associations: extract_associations(model),
596
+ associations: extract_associations(model, source_data),
521
597
  # Reflection has no text for a Proc condition and names `validates_with` by kind only,
522
598
  # so the list is read off the source; an unreadable file falls back to reflection.
523
599
  validations: booted_validations(model, source_data),
@@ -526,8 +602,8 @@ module RailsAiContext
526
602
  # hold no block callbacks, so both tiers read the model's source.
527
603
  callbacks: group_callbacks_by_type(source_data[:callbacks]),
528
604
  callback_conditions: callback_conditions(source_data[:callbacks]),
529
- concerns: booted_concerns(model),
530
- concern_sources: concern_sources(booted_concerns(model), source_data[:mixins], booted: true),
605
+ concerns: concerns,
606
+ concern_sources: concern_sources(concerns, source_data[:mixins], booted: true),
531
607
  concerns_hidden: (hidden.size if hidden.any?),
532
608
  concern_callbacks: concern_callbacks(source_data[:callbacks]),
533
609
  concerns_unread: (unread if unread.any?),
@@ -537,7 +613,9 @@ module RailsAiContext
537
613
  # AST-based (replaces regex source parsing)
538
614
  custom_validates: extract_custom_validates_from_ast(source_data),
539
615
  custom_validate_conditions: custom_validate_conditions(source_data),
616
+ custom_validate_blocks: custom_validate_blocks(source_data),
540
617
  scopes: extract_scopes_from_ast(source_data),
618
+ default_scopes: default_scopes(source_data),
541
619
  class_methods: class_methods.first(PAYLOAD_METHOD_CAP),
542
620
  class_method_count: class_methods.size,
543
621
  instance_methods: instance_methods.first(PAYLOAD_METHOD_CAP),
@@ -547,6 +625,7 @@ module RailsAiContext
547
625
 
548
626
  sti_info = extract_sti_info(model)
549
627
  details[:sti] = sti_info if sti_info
628
+
550
629
  parent = model.superclass
551
630
  details[:parent_model] = parent.name if parent < ActiveRecord::Base && !parent.abstract_class?
552
631
 
@@ -561,6 +640,7 @@ module RailsAiContext
561
640
  # AST-based detailed macros (replaces regex)
562
641
  detailed = extract_detailed_macros_from_ast(source_data)
563
642
  details.merge!(detailed)
643
+ details[:ignored_columns] = model.ignored_columns.map(&:to_s).presence
564
644
 
565
645
  details.compact
566
646
  end
@@ -659,8 +739,7 @@ module RailsAiContext
659
739
  AstWalk.each(AstCache.parse_string(source).value) do |node|
660
740
  next unless node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::SelfNode)
661
741
 
662
- setting = required_default_literal(node)
663
- return setting unless setting.nil?
742
+ return setting_value(node) if node.name == :belongs_to_required_by_default=
664
743
  end
665
744
  end
666
745
  nil
@@ -708,67 +787,79 @@ module RailsAiContext
708
787
  validator.kind == :presence && validator.options[:message] == :required
709
788
  end
710
789
 
711
- # Rails' rule: an explicit optional: wins, then required:, then the app's
712
- # default. An option the source cannot evaluate makes it conditional.
790
+ # Rails' rule: a required: key sets optional: to its negation, then a nil
791
+ # optional: means the app's default. An unevaluable option makes it conditional.
713
792
  def static_required_belongs_to(associations, default = belongs_to_required_by_default?)
714
793
  Array(associations).select { |a| a.is_a?(Hash) && a[:type].to_s == "belongs_to" }.filter_map do |a|
715
794
  name = a[:name].to_s
716
- optional_value = a.key?(:optional) ? a[:optional] : a.dig(:options, :optional)
717
- required_value = a.dig(:options, :required)
718
- optional = literal_boolean(optional_value)
719
- required = literal_boolean(required_value)
720
- if !optional.nil? then (name unless optional)
721
- elsif !optional_value.nil? then [ name, "optional: #{optional_value} is false" ]
722
- elsif !required.nil? then (name if required)
723
- elsif !required_value.nil? then [ name, "required: #{required_value} is true" ]
795
+ options = a[:options] || {}
796
+ optional_value = a.key?(:optional) ? a[:optional] : options[:optional]
797
+ if options.key?(:required)
798
+ required = literal_boolean(options[:required])
799
+ required.nil? ? [ name, "required: #{options[:required]} is true" ] : (name if required)
800
+ elsif !optional_value.nil?
801
+ optional = literal_boolean(optional_value)
802
+ optional.nil? ? [ name, "optional: #{optional_value} is false" ] : (name unless optional)
803
+ elsif default.is_a?(String) then [ name, default ]
724
804
  elsif default then name
725
805
  end
726
806
  end
727
807
  end
728
808
 
729
809
  def literal_boolean(value)
730
- { true => true, false => false, "true" => true, "false" => false }[value]
810
+ { true => true, false => false, nil => false, "true" => true, "false" => false }[value]
731
811
  end
732
812
 
733
- # The last `belongs_to_required_by_default =` Rails would run, from
734
- # config/application.rb then each initializer in load order, on any
735
- # receiver (`config.active_record`, `ActiveRecord::Base`); without one,
736
- # Rails turns it on from `load_defaults 5.0`.
813
+ # Without a `belongs_to_required_by_default =` of its own, Rails turns it
814
+ # on from `load_defaults 5.0`. True, false, or the condition it holds under.
737
815
  def belongs_to_required_by_default?
738
- return @belongs_to_required if defined?(@belongs_to_required)
739
-
740
- root = app.root.to_s
741
- paths = [ File.join(root, "config", "application.rb"),
742
- *Dir.glob(File.join(root, "config", "initializers", "**", "*.rb")).sort ]
743
- setting = nil
744
- version = nil
745
- paths.each do |path|
746
- source = SafeFile.read(path) or next
747
- AstWalk.each(AstCache.parse_string(source).value) do |node|
748
- next unless node.is_a?(Prism::CallNode)
749
-
750
- if node.name == :belongs_to_required_by_default= && node.receiver
751
- literal = required_default_literal(node)
752
- setting = literal unless literal.nil?
753
- elsif node.name == :load_defaults && path.end_with?("application.rb") && node.receiver&.slice.to_s == "config"
754
- version = defaults_version(node.arguments&.arguments&.first)
816
+ setting = framework_setting(:belongs_to_required_by_default=, since: 5.0)
817
+ setting.assigned ? setting.value : setting.version.to_f >= 5.0
818
+ end
819
+
820
+ # `value` is the deciding assignment's setting_value when `assigned`;
821
+ # else `load_defaults`' `version` decides.
822
+ FrameworkSetting = Struct.new(:assigned, :value, :version, :read, keyword_init: true)
823
+
824
+ # Application.rb then the initializers, as Rails runs them; a `load_defaults`
825
+ # of version `since` or later assigns the setting too, and the last line wins.
826
+ def framework_setting(setter, since:)
827
+ @framework_settings ||= {}
828
+ @framework_settings[setter] ||= begin
829
+ root = app.root.to_s
830
+ application = File.join(root, "config", "application.rb")
831
+ setting = FrameworkSetting.new(assigned: false, read: false)
832
+ [ application, *PathResolver.initializer_paths(root) ].each do |path|
833
+ source = SafeFile.read(path) or next
834
+ setting.read ||= path == application
835
+ AstWalk.each(AstCache.parse_string(source).value) do |node|
836
+ next unless node.is_a?(Prism::CallNode)
837
+
838
+ if node.name == setter && node.receiver
839
+ setting.assigned = true
840
+ setting.value = setting_value(node)
841
+ elsif node.name == :load_defaults && path == application && node.receiver&.slice.to_s == "config"
842
+ setting.version = defaults_version(node.arguments&.arguments&.first)
843
+ setting.assigned = false if setting.version.to_f >= since
844
+ end
755
845
  end
756
846
  end
847
+ setting
757
848
  end
758
- @belongs_to_required = setting.nil? ? version.to_f >= 5.0 : setting
759
849
  end
760
850
 
761
- # The true or false a `belongs_to_required_by_default =` call assigns;
762
- # nil for another call or a value the source cannot evaluate.
763
- def required_default_literal(node)
764
- return nil unless node.name == :belongs_to_required_by_default=
765
-
851
+ # A literal true, false or nil (false, as Rails tests for truth) as itself;
852
+ # any other value reads as the condition "<setting> = <expression> is true".
853
+ def setting_value(node)
766
854
  value = node.arguments&.arguments&.first
767
- { Prism::TrueNode => true, Prism::FalseNode => false }[value.class]
855
+ literal = { Prism::TrueNode => true, Prism::FalseNode => false, Prism::NilNode => false }[value.class]
856
+ return literal unless literal.nil? && value
857
+
858
+ "#{node.name.to_s.delete_suffix('=')} = #{value.slice} is true"
768
859
  end
769
860
 
770
- # A literal version as written; anything else (`Rails::VERSION::STRING.to_f`)
771
- # is the running Rails, which is past 5.0.
861
+ # A literal version as written; anything else (`Rails::VERSION::STRING.to_f`) is the
862
+ # running Rails, Float::INFINITY: on for belongs_to, unknown for the commit order.
772
863
  def defaults_version(arg)
773
864
  case arg
774
865
  when Prism::FloatNode, Prism::IntegerNode then arg.value.to_f
@@ -782,11 +873,31 @@ module RailsAiContext
782
873
  # ActiveRecord::Base's chain, which is what a gem (or an initializer)
783
874
  # puts into all models. A module a gem macro includes into this model
784
875
  # alone (`devise :lockable`) stays.
785
- def booted_concerns(model)
786
- own = (model.ancestors - every_model_modules).reject { |mod| mod.is_a?(Class) }.filter_map(&:name).reverse
876
+ def booted_concerns(model, mixins = [])
877
+ own = (model.ancestors - every_model_modules - base_gem_modules(model, mixins)).reject { |mod| mod.is_a?(Class) }.filter_map(&:name).reverse
787
878
  ConcernMembership.payload(own)
788
879
  end
789
880
 
881
+ # A module a gem's inherited hook mixes into every model (Kaminari) is left out; one the app includes or defines stays.
882
+ def base_gem_modules(model, mixins)
883
+ written = ConcernMembership.from_mixins(mixins)
884
+ gem_bases(model).flat_map { |base| base.ancestors - every_model_modules }.uniq.select do |mod|
885
+ !mod.is_a?(Class) && mod.name && !written.include?(mod.name) && !ConcernPaths.find_file(app.root.to_s, mod.name)
886
+ end
887
+ end
888
+
889
+ def abstract_bases(model)
890
+ model.ancestors.select { |klass| klass.is_a?(Class) && klass < ActiveRecord::Base && klass != model && klass.abstract_class? }
891
+ end
892
+
893
+ # The app's bases a gem's inherited hook reaches as it reaches the model (Kaminari: each direct ActiveRecord::Base child).
894
+ def gem_bases(model)
895
+ bases = abstract_bases(model)
896
+ return bases unless model.superclass == ActiveRecord::Base
897
+
898
+ bases | ActiveRecord::Base.subclasses.select { |klass| klass.name && klass.abstract_class? }
899
+ end
900
+
790
901
  def every_model_modules
791
902
  @every_model_modules ||= ActiveRecord::Base.ancestors
792
903
  end
@@ -824,15 +935,32 @@ module RailsAiContext
824
935
 
825
936
  # ── Reflection-based extraction (unchanged) ─────────────────────
826
937
 
827
- def extract_associations(model)
938
+ def extract_associations(model, source_data = empty_source_data)
939
+ declared = Array(source_data[:associations]).group_by { |a| [ a[:type].to_s, a[:name].to_s ] }
828
940
  # The reject stays ahead of the map: class_name/foreign_key on an
829
941
  # excluded reflection with a broken :through raises, and `call`'s
830
942
  # per-model rescue would replace the whole model with one error line.
831
943
  model.reflect_on_all_associations.reject { |assoc| excluded_association?(assoc.name) }.map do |assoc|
832
- association_detail(assoc)
944
+ with_declared_options(association_detail(assoc), declared[[ assoc.macro.to_s, assoc.name.to_s ]]&.first, model)
833
945
  end
834
946
  end
835
947
 
948
+ # Only what the source declares, so both tiers print one spelling; a gem
949
+ # macro's generated association carries Rails' options, not the app's.
950
+ def with_declared_options(detail, source, model = nil)
951
+ options = declared_association_options(detail[:type], source&.dig(:options))
952
+ extensions = source&.dig(:extension_methods)
953
+ delegated = source&.dig(:delegated_types)
954
+ unread = source&.dig(:delegated_types_source)
955
+ # delegated_type defines `<role>_types`, which answers a list the source names only as an expression.
956
+ if unread && model.respond_to?(reader = :"#{detail[:name]}_types")
957
+ delegated = Array(model.public_send(reader)).map(&:to_s).presence
958
+ unread = nil if delegated
959
+ end
960
+ detail.merge({ declared_options: options, extension_methods: extensions, delegated_types: delegated,
961
+ delegated_types_source: unread }.compact)
962
+ end
963
+
836
964
  # One reflection that cannot resolve costs that reflection, not the
837
965
  # model. `class_name` on a `:through` whose through association does not
838
966
  # exist ends in `nil.klass`, and the per-model rescue in `call` then
@@ -908,10 +1036,11 @@ module RailsAiContext
908
1036
  end
909
1037
 
910
1038
  def extract_sti_info(model)
1039
+ type_column = model.inheritance_column.to_s
911
1040
  has_type_column = if model.connected? && model.table_exists?
912
- model.columns_hash.key?("type")
1041
+ model.columns_hash.key?(type_column)
913
1042
  else
914
- SchemaReader.for(app.root).column?(model.table_name, "type")
1043
+ SchemaReader.for(app.root).column?(model.table_name, type_column)
915
1044
  end
916
1045
 
917
1046
  return nil unless has_type_column
@@ -933,7 +1062,8 @@ module RailsAiContext
933
1062
  {
934
1063
  sti_base: sti_parent.nil? && children.any?,
935
1064
  sti_parent: sti_parent,
936
- sti_children: children.empty? ? nil : children
1065
+ sti_children: children.empty? ? nil : children,
1066
+ type_column: type_column
937
1067
  }.compact
938
1068
  rescue => e
939
1069
  RailsAiContext.debug_fail(e, nil, label: "extract_sti_info")
@@ -942,7 +1072,7 @@ module RailsAiContext
942
1072
  # ── AST-based extraction (replaces all regex parsing) ──────────
943
1073
 
944
1074
  def extract_scopes_from_ast(source_data)
945
- source_data[:scopes].map do |s|
1075
+ source_data[:scopes].reject { |s| s[:default] }.map do |s|
946
1076
  {
947
1077
  name: s[:name],
948
1078
  body: s[:body],
@@ -952,12 +1082,26 @@ module RailsAiContext
952
1082
  end
953
1083
  end
954
1084
 
1085
+ # Base first, as Rails stacks them; each one filters every query.
1086
+ def default_scopes(source_data)
1087
+ found = Array(source_data[:scopes]).select { |s| s[:default] }.map { |s| s.slice(:body, :all_queries, :confidence) }
1088
+ found.presence
1089
+ end
1090
+
955
1091
  def extract_custom_validates_from_ast(source_data)
956
1092
  source_data[:validations]
957
1093
  .select { |v| v[:kind] == "custom" }
958
1094
  .flat_map { |v| v[:attributes] }
959
1095
  end
960
1096
 
1097
+ # A validate block is listed by what it runs; Rails registers it next to any methods named.
1098
+ def custom_validate_blocks(source_data)
1099
+ found = Array(source_data[:validations]).select { |v| v[:kind] == "custom" && v[:block] }.map do |v|
1100
+ { body: v[:block], conditions: (v[:options] || {}).slice(*CONDITION_KEYS, :on).presence }.compact
1101
+ end
1102
+ found.presence
1103
+ end
1104
+
961
1105
  # `validate :check, on: :create` runs only then; the method name alone
962
1106
  # reads as a rule that always holds.
963
1107
  def custom_validate_conditions(source_data)
@@ -968,26 +1112,28 @@ module RailsAiContext
968
1112
  found.presence
969
1113
  end
970
1114
 
1115
+ # The options that decide the value a new record holds and the names of
1116
+ # the methods the enum defines.
971
1117
  def extract_enum_options_from_ast(source_data)
972
- source_data[:enums].each_with_object({}) do |enum, opts|
973
- entry = {}
974
- entry[:prefix] = enum[:options][:prefix] || enum[:options][:_prefix] if enum[:options][:prefix] || enum[:options][:_prefix]
975
- entry[:suffix] = enum[:options][:suffix] || enum[:options][:_suffix] if enum[:options][:suffix] || enum[:options][:_suffix]
976
- opts[enum[:name]] = entry if entry.any?
1118
+ Array(source_data[:enums]).each_with_object({}) do |enum, opts|
1119
+ options = enum[:options] || {}
1120
+ entry = %i[prefix suffix default].to_h { |key| [ key, options.fetch(key) { options[:"_#{key}"] } ] }
1121
+ entry.reject! { |_, value| value.nil? || value == false }
1122
+ opts[enum[:name].to_s] = entry if entry.any?
977
1123
  end
978
1124
  end
979
1125
 
980
1126
  CONDITION_KEYS = %i[if unless].freeze
981
1127
 
982
- # One conditions entry per callback occurrence, in step with the name list: a model can
983
- # declare one method twice under different conditions.
1128
+ # One conditions entry per callback, in step with the name list: one method can sit under
1129
+ # two types with different conditions.
984
1130
  def callback_conditions(callbacks)
985
1131
  Array(callbacks).each_with_object({}) do |cb, hash|
986
1132
  next unless cb.is_a?(Hash) && cb[:type]
987
1133
 
988
- # A plain after_commit carries the events its one declaration names.
989
- keys = cb[:type].to_s == "after_commit" ? CONDITION_KEYS + [ :on ] : CONDITION_KEYS
990
- conditions = (cb[:options] || {}).slice(*keys)
1134
+ options = cb[:options] || {}
1135
+ conditions = options.select { |key, _| CONDITION_KEYS.include?(key) }
1136
+ conditions = { on: options[:on] }.merge(conditions) if options.key?(:on) && !Listeners::CallbacksListener.names_event?(cb[:type])
991
1137
  (hash[cb[:type].to_s] ||= []) << (conditions.empty? ? nil : conditions)
992
1138
  end.reject { |_, list| list.none? }
993
1139
  end
@@ -1018,7 +1164,10 @@ module RailsAiContext
1018
1164
  .reject { |m| scope_names.include?(m) }
1019
1165
 
1020
1166
  # Reflection-discovered class methods (for completeness)
1021
- all_methods = (model.methods - ActiveRecord::Base.methods - Object.methods)
1167
+ # The abstract bases' class methods reach every model, the static tier reads none of
1168
+ # them, and a gem's (Kaminari's `page`) would otherwise be listed on each model.
1169
+ base_methods = gem_bases(model).flat_map(&:methods)
1170
+ all_methods = (model.methods - ActiveRecord::Base.methods - Object.methods - base_methods)
1022
1171
  .reject { |m|
1023
1172
  ms = m.to_s
1024
1173
  ms == "self" ||
@@ -1073,12 +1222,11 @@ module RailsAiContext
1073
1222
  has_rich_text: :has_rich_text,
1074
1223
  generates_token_for: :generates_token_for,
1075
1224
  serialize: :serialize,
1076
- store: :store,
1077
- store_accessor: :store
1225
+ has_secure_token: :has_secure_token,
1226
+ attr_readonly: :attr_readonly,
1227
+ query_constraints: :query_constraints
1078
1228
  }.freeze
1079
1229
 
1080
- BROADCAST_MACROS = %i[broadcasts broadcasts_to broadcasts_refreshes_to].to_set.freeze
1081
-
1082
1230
  def extract_macros_from_ast(source_data, source_path = nil)
1083
1231
  macros = {}
1084
1232
  source_data[:macros].each do |m|
@@ -1086,17 +1234,36 @@ module RailsAiContext
1086
1234
 
1087
1235
  if macro == :has_secure_password
1088
1236
  macros[:has_secure_password] = true
1237
+ (macros[:secure_passwords] ||= []) << { attribute: m[:attribute], options: m[:written] || {} }
1089
1238
  elsif (key = ATTRIBUTE_MACRO_MAP[macro])
1090
1239
  (macros[key] ||= []) << m[:attribute]
1240
+ (macros[:serialize_options] ||= {})[m[:attribute]] = m[:written] if macro == :serialize && m[:written]&.any?
1241
+ elsif Listeners::MacrosListener::STORE_MACROS.include?(macro)
1242
+ add_store_accessors(macros, m)
1243
+ elsif macro == :accepts_nested_attributes_for
1244
+ (macros[:nested_attributes] ||= []) << { names: m[:names], options: m[:options] }.compact
1245
+ elsif macro == :ignored_columns
1246
+ macros[:ignored_columns] = apply_ignored_columns(macros[:ignored_columns] || [], m)
1091
1247
  elsif macro == :delegate
1092
- (macros[:delegations] ||= []) << { methods: m[:methods], to: m[:to] }
1248
+ (macros[:delegations] ||= []) << delegation_entry(m)
1093
1249
  elsif macro == :delegate_missing_to
1094
1250
  macros[:delegate_missing_to] = m[:to]
1095
1251
  elsif macro == :attribute
1096
- (macros[:attributes] ||= []) << { name: m[:attribute], type: m[:type] }.compact
1252
+ (macros[:attributes] ||= []) << { name: m[:attribute], type: m[:type], default: m.dig(:options, :default) }.compact
1253
+ elsif macro == :alias_attribute
1254
+ (macros[:alias_attributes] ||= []) << { name: m[:attribute], target: m[:target] }
1255
+ elsif macro == :model_setting
1256
+ # Bases arrive first, so the class's own assignment wins.
1257
+ (macros[:model_settings] ||= {})[m[:setting]] = m[:value]
1258
+ elsif macro == :connects_to
1259
+ macros[:database] = { connects_to: m[:text], condition: m[:condition], declared_in: m[:declared_in], writing: m[:writing] }.compact
1260
+ elsif macro == :gem_macro
1261
+ (macros[:gem_macros] ||= []) << m.slice(:text, :adds)
1262
+ elsif macro == :aasm
1263
+ (macros[:state_machines] ||= []) << m.slice(:column, :initial, :states, :events)
1097
1264
  end
1098
1265
 
1099
- if BROADCAST_MACROS.include?(macro)
1266
+ if Listeners::MacrosListener::BROADCAST_MACROS.include?(macro)
1100
1267
  (macros[:broadcasts] ||= []) << macro.to_s
1101
1268
  macros[:broadcasts].uniq!
1102
1269
  end
@@ -1108,6 +1275,59 @@ module RailsAiContext
1108
1275
  macros.reject { |_, v| v.is_a?(Array) && v.empty? }
1109
1276
  end
1110
1277
 
1278
+ # Declarations arrive bases first, so `=` replaces what a base set and
1279
+ # `+=` adds to it; a list the source computes cannot be read.
1280
+ def apply_ignored_columns(current, macro)
1281
+ return [ RailsAiContext::Confidence::INFERRED ] if macro[:columns].nil? || current.include?(RailsAiContext::Confidence::INFERRED)
1282
+
1283
+ case macro[:op]
1284
+ when :assign then macro[:columns].uniq
1285
+ when :add then (current + macro[:columns]).uniq
1286
+ else current - macro[:columns]
1287
+ end
1288
+ end
1289
+
1290
+ # Rails names a store accessor "#{prefix}_#{key}_#{suffix}", where true
1291
+ # stands for the column name (ActiveRecord::Store.store_accessor).
1292
+ def add_store_accessors(macros, macro)
1293
+ column = macro[:attribute]
1294
+ (macros[:store] ||= []) << column unless macros[:store]&.include?(column)
1295
+ keys = macro[:keys] || []
1296
+ return if keys.empty?
1297
+
1298
+ options = macro[:options] || {}
1299
+ accessors = (macros[:store_accessors] ||= {})[column] ||= []
1300
+ affixes = options.values_at(:prefix, :suffix)
1301
+ if affixes.include?(RailsAiContext::Confidence::INFERRED)
1302
+ accessors << RailsAiContext::Confidence::INFERRED unless accessors.include?(RailsAiContext::Confidence::INFERRED)
1303
+ return
1304
+ end
1305
+
1306
+ prefix, suffix = affixes.map { |affix| affix == true ? column : (affix.is_a?(Symbol) || affix.is_a?(String) ? affix : nil) }
1307
+ keys.each do |key|
1308
+ name = [ prefix, key, suffix ].compact.join("_")
1309
+ accessors << name unless accessors.include?(name)
1310
+ end
1311
+ end
1312
+
1313
+ # ActiveSupport names a prefixed delegate "#{prefix == true ? to : prefix}_#{method}".
1314
+ def delegation_entry(macro)
1315
+ options = macro[:options] || {}
1316
+ entry = { methods: macro[:methods], to: macro[:to] }
1317
+ entry[:private] = true if options[:private] == true
1318
+ prefix = options[:prefix]
1319
+ case prefix
1320
+ when true, Symbol, String
1321
+ if prefix == RailsAiContext::Confidence::INFERRED
1322
+ entry[:prefix_computed] = true
1323
+ else
1324
+ head = prefix == true ? macro[:to] : prefix
1325
+ entry[:defines] = macro[:methods].map { |name| "#{head}_#{name}" } if head
1326
+ end
1327
+ end
1328
+ entry
1329
+ end
1330
+
1111
1331
  # Extract constant definitions from source via AST.
1112
1332
  # Finds ConstantWriteNode where the value is an ArrayNode
1113
1333
  # (covers %w[], %i[], and literal array forms).
@@ -1128,9 +1348,7 @@ module RailsAiContext
1128
1348
  name = node.name.to_s
1129
1349
  # Only capture UPPER_CASE constants (matching the old regex behavior)
1130
1350
  if name.match?(/\A[A-Z][A-Z_]+\z/)
1131
- # Unwrap .freeze if present: STATUSES = %w[...].freeze
1132
- value_node = node.value
1133
- value_node = value_node.receiver if value_node.is_a?(Prism::CallNode) && value_node.name == :freeze
1351
+ value_node = unwrap_frozen(node.value)
1134
1352
 
1135
1353
  if value_node.is_a?(Prism::ArrayNode)
1136
1354
  values = value_node.elements.filter_map { |el|
@@ -1147,25 +1365,37 @@ module RailsAiContext
1147
1365
  node.child_nodes.compact.each { |child| find_constant_arrays(child, constants) }
1148
1366
  end
1149
1367
 
1368
+ # `%w[...].freeze` and `Ractor.make_shareable(%w[...])` are the array itself, frozen.
1369
+ def unwrap_frozen(node)
1370
+ loop do
1371
+ break node unless node.is_a?(Prism::CallNode)
1372
+
1373
+ if node.name == :freeze && node.arguments.nil?
1374
+ node = node.receiver
1375
+ elsif node.name == :make_shareable && node.receiver&.slice&.delete_prefix("::") == "Ractor" && node.arguments&.arguments&.size == 1
1376
+ node = node.arguments.arguments.first
1377
+ else
1378
+ break node
1379
+ end
1380
+ end
1381
+ end
1382
+
1150
1383
  def extract_detailed_macros_from_ast(source_data)
1151
1384
  encryption = []
1152
1385
  normalizations = []
1153
1386
  tokens = []
1154
1387
 
1155
1388
  source_data[:macros].each do |m|
1389
+ written = m[:written] || {}
1156
1390
  case m[:macro]
1157
1391
  when :encrypts
1158
- opts = {}
1159
- opts[:deterministic] = true if m[:options][:deterministic] == true
1160
- opts[:downcase] = true if m[:options][:downcase] == true
1161
- encryption << { field: m[:attribute], options: opts }
1392
+ encryption << { field: m[:attribute], options: written }
1162
1393
  when :normalizes
1163
- entry = { field: m[:attribute] }
1164
- entry[:transformation] = m[:options][:with].to_s if m[:options][:with]
1394
+ entry = { field: m[:attribute], transformation: written[:with], options: written.except(:with).presence }
1165
1395
  normalizations << entry.compact
1166
1396
  when :generates_token_for
1167
1397
  entry = { purpose: m[:attribute] }
1168
- entry[:expires_in] = m[:options][:expires_in].to_s if m[:options][:expires_in]
1398
+ entry[:expires_in] = written[:expires_in] if written[:expires_in]
1169
1399
  tokens << entry.compact
1170
1400
  end
1171
1401
  end
@@ -1275,6 +1505,31 @@ module RailsAiContext
1275
1505
  ].freeze
1276
1506
  BOOLEAN_ASSOCIATION_OPTIONS = %i[polymorphic optional].freeze
1277
1507
 
1508
+ # What the record already says another way, or what Rails keeps for itself. A has_many's declared
1509
+ # foreign key stays: its record carries reflection's default key too.
1510
+ UNLISTED_ASSOCIATION_OPTIONS = [ *(LIFTED_ASSOCIATION_OPTIONS - [ :foreign_key ]), :anonymous_class ].freeze
1511
+
1512
+ # Every other option the association declares, each as display text:
1513
+ # its callbacks, extensions, counter cache and the rest.
1514
+ def declared_association_options(type, options)
1515
+ return nil unless options.is_a?(Hash)
1516
+
1517
+ shown = options.except(*UNLISTED_ASSOCIATION_OPTIONS)
1518
+ shown = shown.except(:required, :foreign_key, :query_constraints) if type.to_s == "belongs_to"
1519
+ shown.to_h { |key, value| [ key.to_s, association_option_text(value) ] }.presence
1520
+ end
1521
+
1522
+ def association_option_text(value)
1523
+ case value
1524
+ when Symbol then ":#{value}"
1525
+ when Module then value.name.to_s
1526
+ when Proc then "(proc)"
1527
+ when Array then "[#{value.map { |v| association_option_text(v) }.join(', ')}]"
1528
+ when Hash then "{ #{value.map { |k, v| "#{k}: #{association_option_text(v)}" }.join(', ')} }"
1529
+ else value.to_s
1530
+ end
1531
+ end
1532
+
1278
1533
  # A habtm join_table built from the class's affixes, as the table it names.
1279
1534
  def with_join_tables(associations, path, class_name)
1280
1535
  own = nil
@@ -1308,7 +1563,18 @@ module RailsAiContext
1308
1563
  else value.to_s
1309
1564
  end
1310
1565
  end
1311
- with_default_foreign_key(lifted)
1566
+ lifted = with_rails_option_rules(lifted, options)
1567
+ declared = declared_association_options(lifted[:type], options)
1568
+ with_default_foreign_key(declared ? lifted.merge(declared_options: declared) : lifted)
1569
+ end
1570
+
1571
+ # `required:` on a belongs_to sets `optional:` to its negation; Rails 7.1 takes `query_constraints:` as the foreign key.
1572
+ def with_rails_option_rules(assoc, options)
1573
+ required = literal_boolean(options[:required]) if options.key?(:required)
1574
+ assoc = assoc.merge(optional: !required) if assoc[:type] == "belongs_to" && !required.nil?
1575
+ keys = options[:query_constraints]
1576
+ assoc = assoc.merge(foreign_key: keys.map(&:to_s)) if keys.is_a?(Array) && !assoc.key?(:foreign_key)
1577
+ assoc
1312
1578
  end
1313
1579
 
1314
1580
  # Reflection answers a belongs_to's key when none is declared: the name plus _id.
@@ -1338,10 +1604,11 @@ module RailsAiContext
1338
1604
  end
1339
1605
 
1340
1606
  def static_model_details(path, class_name, file: relative_to_root(path), table_name: nil, inherited_from: [],
1341
- sti: nil, parent_model: nil)
1607
+ sti: nil, parent_model: nil, primary_key: nil)
1342
1608
  own = own_body(source_walk(path), class_name)
1343
- calls = class_calls([ path, *Array(inherited_from).map(&:last) ])
1344
- data, unread, bases_unread, hidden = merge_class_and_bases(own, class_name, calls, inherited_from, file: path)
1609
+ calls = singleton_lookup([ [ class_name, path ], *Array(inherited_from) ])
1610
+ data, unread, bases_unread, hidden = merge_class_and_bases(own, class_name, calls, inherited_from, file: path,
1611
+ commits_in_order: static_commits_in_order)
1345
1612
  own_methods = ActionResolver.own_methods(own[:methods], class_name)
1346
1613
  scope_names = Array(data[:scopes]).filter_map { |scope| scope[:name]&.to_s }.to_set
1347
1614
  static_instance_methods = own_methods.select { |m| m[:scope] == :instance && m[:visibility] == :public }.map { |m| m[:name].to_s }
@@ -1350,15 +1617,19 @@ module RailsAiContext
1350
1617
  details = {
1351
1618
  confidence: Confidence::STATIC,
1352
1619
  table_name: table_name || TableName.stem(path),
1620
+ primary_key: primary_key,
1353
1621
  associations: with_join_tables(reject_excluded_associations(data[:associations]), path, class_name),
1354
1622
  validations: static_validations(data, [ path, *Array(inherited_from).map(&:last) ]),
1355
1623
  custom_validates: extract_custom_validates_from_ast(data),
1356
1624
  custom_validate_conditions: custom_validate_conditions(data),
1357
- scopes: data[:scopes],
1625
+ custom_validate_blocks: custom_validate_blocks(data),
1626
+ scopes: Array(data[:scopes]).reject { |s| s[:default] },
1627
+ default_scopes: default_scopes(data),
1358
1628
  # The booted tier answers a Hash of attribute => value map, and
1359
1629
  # every renderer destructures one; the listener's records are a
1360
1630
  # different shape under the same key.
1361
1631
  enums: static_enums(data[:enums]),
1632
+ enum_options: extract_enum_options_from_ast(data).presence,
1362
1633
  # Same shape as the booted tier: a Hash keyed by callback type. The
1363
1634
  # listener hands back a flat Array, and every consumer filters on
1364
1635
  # `callbacks.is_a?(Hash)` - so passing it through rendered "No models
@@ -1372,6 +1643,7 @@ module RailsAiContext
1372
1643
  concern_callbacks: concern_callbacks(data[:callbacks]),
1373
1644
  concerns_unread: (unread if unread.any?),
1374
1645
  bases_unread: (bases_unread if bases_unread.any?),
1646
+ commit_order_unread: commit_order_unread(data[:callbacks]),
1375
1647
  conditional_declarations: declarations(data[:conditional]),
1376
1648
  foreign_declarations: declarations(data[:foreign]),
1377
1649
  macros: data[:macros],
@@ -1391,6 +1663,8 @@ module RailsAiContext
1391
1663
  }
1392
1664
  details.merge!(extract_macros_from_ast(data, path))
1393
1665
  details.merge!(extract_detailed_macros_from_ast(data))
1666
+ type_column = static_type_column(data)
1667
+ details[:sti] = sti && type_column && !lacks_column?(details[:table_name], type_column) ? sti.merge(type_column: type_column) : nil
1394
1668
  downgrade_records(details.compact)
1395
1669
  end
1396
1670
 
@@ -1400,6 +1674,9 @@ module RailsAiContext
1400
1674
  # associations, on that tier.
1401
1675
  MERGED_CONCERN_KEYS = %i[associations validations scopes enums callbacks macros].freeze
1402
1676
 
1677
+ # The class's own declarations a method body can hold; MacrosListener skips method bodies itself.
1678
+ METHOD_PLACED_KEYS = %i[validations associations scopes enums].freeze
1679
+
1403
1680
  # The merged keys, plus what a mixin's macro methods declare only under a
1404
1681
  # condition the call cannot decide.
1405
1682
  WALKED_KEYS = [ *MERGED_CONCERN_KEYS, :conditional, :foreign ].freeze
@@ -1408,36 +1685,73 @@ module RailsAiContext
1408
1685
  # so on the booted tier an unread concern costs the other keys only.
1409
1686
  REFLECTED_CONCERN_KEYS = %i[associations validations enums].freeze
1410
1687
 
1411
- # The mixin names are in the same walk and their files are on disk, so
1412
- # the class's own declarations and its concerns' answer as one. Methods
1413
- # and mixins stay the model's own: those are its interface, not the
1414
- # sum of what it included.
1415
- # The modules mixed into every model from outside, and the app modules
1416
- # the class extends itself with: a macro method one of them defines is
1417
- # read where the class calls it. An extended module whose file is not the
1418
- # app's is a gem's, and a gem's macro methods are not read.
1419
- def class_extras(own, class_name)
1420
- extended = Array(own[:mixins]).filter_map do |mixin|
1421
- next unless mixin[:macro] == :extend && !mixin[:receiver]
1422
- next unless ConcernMembership.candidate?(mixin[:name])
1423
-
1424
- path = ConcernPaths.find_file(app.root.to_s, mixin[:name], prefer: "model", within: class_name)
1425
- BaseMixins::Mixin.new(mixin[:name], path, :extend) if path
1426
- end
1427
- BaseMixins.models(app.root.to_s) + extended
1688
+ # The walk only collects: where a declaration lands waits for every class's walk.
1689
+ Walk = Struct.new(:own, :collected, :unread, :hidden, :skipped, :rank) do
1690
+ def self.empty(own) = new(own, {}, [], [], Set.new, 0)
1428
1691
  end
1429
1692
 
1430
- def merge_concern_macros(own, class_name, calls = nil, extra: [], file: nil)
1431
- collected, unread, hidden, included, _placement, skipped = ConcernMacros.collect(
1693
+ def walk_class(own, class_name, calls, extra: [], file: nil, rank: 0)
1694
+ found = ConcernMacros.collect(
1432
1695
  app.root.to_s, own[:mixins] || [],
1433
1696
  keys: [ *WALKED_KEYS, :expanded ], prefer: "model", within: class_name,
1434
1697
  cache: @source_cache, calls: calls, extra: extra, file: file
1435
1698
  )
1436
- ConcernMacros::Run.merge_calls(calls.included, included) if calls.respond_to?(:included)
1437
- own = without_expanded_calls(own, collected.delete(:expanded))
1438
- return [ own, unread, hidden, skipped ] if collected.empty?
1699
+ placement = found.placement
1700
+ included_at = included_at(own, placement)
1701
+ every = extra.map(&:name).to_set
1702
+ bodies = method_bodies(own)
1703
+ mixins = found.mixins.map do |label, macro, defs, in_module|
1704
+ line, order = included_at.call(label)
1705
+ added, line = in_module || [ ConcernMacros::SingletonLookup.added_in(rank, line, bodies), line ]
1706
+ ConcernMacros::SingletonLookup::Mixin.new(label, macro, defs, added, [ line, order ], every.include?(placement.dig(label, 0)))
1707
+ end
1708
+ calls.add(rank, found.included_calls, found.block_sites, mixins)
1709
+ Walk.new(own, found.collected, found.unread, found.hidden, found.skipped, rank)
1710
+ end
1439
1711
 
1440
- [ merge_inherited(own, collected), unread, hidden, skipped ]
1712
+ # Where a concern the walk read joins the class's chain: at the line
1713
+ # including it, after the concerns that include brought in before it.
1714
+ def included_at(own, placement)
1715
+ line_of = Array(own[:mixins]).reverse.to_h { |mixin| [ mixin[:name], mixin[:location].to_i ] }
1716
+ lambda do |name|
1717
+ top, order = placement[name]
1718
+ top ? [ line_of[top].to_i, order.to_i ] : [ 0, 0 ]
1719
+ end
1720
+ end
1721
+
1722
+ # A walk's declarations where Ruby runs them: one in a method body, the class's own or a
1723
+ # module's, once per call running that body; a concern's where its code runs.
1724
+ def settle(walk, calls, expanded = nil)
1725
+ own, rank = walk.own, walk.rank
1726
+ collected = walk.collected.to_h do |key, entries|
1727
+ [ key, entries.flat_map { |entry| place(entry, rank, calls) }.then { |found| key == :callbacks ? found : found.map { |entry| entry.except(*CHAIN_KEYS) }.uniq } ]
1728
+ end
1729
+ own = without_expanded_calls(own, Array(collected.delete(:expanded)) + Array(expanded))
1730
+ bodies = method_bodies(own)
1731
+ callbacks = Array(own[:callbacks]).flat_map { |cb| placed_in_method(cb, bodies, rank, calls) || [ cb.merge(rank: rank) ] }
1732
+ own = own.merge(callbacks: callbacks + Array(collected.delete(:callbacks)))
1733
+ own = own.merge(METHOD_PLACED_KEYS.to_h { |key| [ key, Array(own[key]).select { |entry| (placed_in_method(entry, bodies, rank, calls) || [ entry ]).any? } ] })
1734
+ [ collected.empty? ? own : merge_inherited(own, collected), walk.unread, walk.hidden ]
1735
+ end
1736
+
1737
+ # Nil outside a method body; else where the calls running that method place the
1738
+ # declaration, none for an instance method.
1739
+ def placed_in_method(entry, bodies, rank, calls)
1740
+ line = entry[:location] if entry.is_a?(Hash)
1741
+ method = line && ConcernMacros.enclosing(bodies, line)&.last
1742
+ # A `def self.default_scope` is the declaration itself, not a body holding one.
1743
+ return nil if method.nil? || (method[:name] == entry[:name] && method[:location] == line)
1744
+ return [] unless method[:scope] == :class
1745
+
1746
+ calls.placed(entry, [ rank, method[:location] ], [ line ])
1747
+ end
1748
+
1749
+ def place(entry, rank, calls)
1750
+ entry.is_a?(Hash) ? calls.mixed_in(entry, rank) : [ entry ]
1751
+ end
1752
+
1753
+ def method_bodies(own)
1754
+ Array(own[:methods]).filter_map { |m| [ m[:location]..m[:end_location], m ] if m[:location] && m[:end_location] }
1441
1755
  end
1442
1756
 
1443
1757
  # A `validates_translation :title` call the listener read as a validation
@@ -1457,33 +1771,133 @@ module RailsAiContext
1457
1771
  #
1458
1772
  # Each side is walked again only when the other has since learned a call
1459
1773
  # that releases something it held back.
1460
- def merge_class_and_bases(own, class_name, calls, bases, file:)
1461
- extra = class_extras(own, class_name)
1462
- mine = merge_concern_macros(own, class_name, calls, extra: extra, file: file)
1774
+ def merge_class_and_bases(own, class_name, calls, bases, file:, commits_in_order:)
1775
+ extra = BaseMixins.models(app.root.to_s)
1776
+ mine = walk_class(own, class_name, calls, extra: extra, file: file)
1463
1777
  walked = walk_bases(bases, calls)
1464
1778
  ConcernMacros::MAX_DEPTH.times do
1465
- known = Set.new(calls.call.keys)
1466
- break unless mine.last.intersect?(known)
1779
+ known = Set.new(calls.sites_by_name.keys)
1780
+ break unless mine.skipped.intersect?(known)
1467
1781
 
1468
- mine = merge_concern_macros(own, class_name, calls, extra: extra, file: file)
1469
- held = walked.flat_map { |_name, base_own, *rest| base_own ? Array(rest.last).to_a : [] }.to_set
1470
- grown = Set.new(calls.call.keys) - known
1782
+ mine = walk_class(own, class_name, calls, extra: extra, file: file)
1783
+ held = walked.flat_map { |_name, walk| walk ? walk.skipped.to_a : [] }.to_set
1784
+ grown = Set.new(calls.sites_by_name.keys) - known
1471
1785
  walked = walk_bases(bases, calls) if held.intersect?(grown)
1472
1786
  end
1473
- data, unread, hidden = mine
1474
- merge_inherited_macros(data, unread, hidden, walked)
1787
+ declared = base_declarations(calls, [ [ class_name, file ], *bases ])
1788
+ expanded = Array(declared.delete(:expanded)).group_by { |call| call[:rank] }
1789
+ data, unread, hidden = settle(mine, calls, expanded[0])
1790
+ data = merge_inherited(data, declared)
1791
+ settled = walked.map { |name, walk| walk ? [ name, walk.own, *settle(walk, calls, expanded[walk.rank]) ] : [ name, nil ] }
1792
+ data, *rest = merge_inherited_macros(data, unread, hidden, settled)
1793
+ [ data.merge(callbacks: chain_order(data[:callbacks], commits_in_order)), *rest ]
1794
+ end
1795
+
1796
+ CHAIN_KEYS = %i[rank chain_at hook owner].freeze
1797
+
1798
+ # What the class methods the app's base bodies give the class declare, at each call reaching them,
1799
+ # with the calls read that way (`:expanded`) by the rank they stand at.
1800
+ def base_declarations(calls, classes)
1801
+ found = Hash.new { |hash, key| hash[key] = [] }
1802
+ base_defs(classes).each do |node, definition|
1803
+ next unless node.is_a?(Prism::DefNode)
1804
+
1805
+ sites = Array(calls.sites_by_name[definition.name]).compact
1806
+ expansion, = ConcernMacros.expand_calls(node, sites, [ *WALKED_KEYS, :expanded ], SourceIntrospector::LISTENER_MAP) do |entry, site|
1807
+ calls.placed(entry, definition.key, [ entry[:location] ], site)
1808
+ end
1809
+ expansion.each do |key, entries|
1810
+ found[key].concat(%i[callbacks expanded].include?(key) ? entries : entries.map { |entry| entry.except(*CHAIN_KEYS) }.uniq)
1811
+ end
1812
+ end
1813
+ found.to_h
1814
+ end
1815
+
1816
+ # Rails builds the chain from the outermost base in, each class in the
1817
+ # order its body runs.
1818
+ def chain_order(callbacks, commits_in_order)
1819
+ sorted = Array(callbacks).each_with_index.sort_by do |cb, index|
1820
+ [ -cb[:rank].to_i, cb[:chain_at] || [ cb[:location].to_i, -1 ], index ]
1821
+ end
1822
+ run_order(callback_chain(without_skipped(sorted.map(&:first))), commits_in_order).map { |cb| cb.except(*CHAIN_KEYS) }
1823
+ end
1824
+
1825
+ # A `skip_callback` removes the matching callback already in the chain;
1826
+ # with `if:`/`unless:` Rails keeps it under the negated condition.
1827
+ def without_skipped(callbacks)
1828
+ callbacks.each_with_object([]) do |cb, kept|
1829
+ next kept << cb unless cb[:skip]
1830
+
1831
+ key = Listeners::CallbacksListener.chain_key(cb[:type])
1832
+ matching = ->(k) { Listeners::CallbacksListener.chain_key(k[:type]) == key && k[:method].to_s == cb[:method].to_s }
1833
+ conditions = (cb[:options] || {}).slice(:if, :unless)
1834
+ if conditions.empty?
1835
+ kept.reject!(&matching)
1836
+ else
1837
+ kept.map! { |k| matching.call(k) ? k.merge(options: negated_conditions(k[:options] || {}, conditions)) : k }
1838
+ end
1839
+ end
1840
+ end
1841
+
1842
+ def negated_conditions(options, skip)
1843
+ flipped = { unless: skip[:if], if: skip[:unless] }.compact
1844
+ options.merge(flipped) { |_key, ours, theirs| [ *Array(ours), theirs ] }
1845
+ end
1846
+
1847
+ # A prepended callback goes to the front and after callbacks run from the
1848
+ # back; ActiveModel prepends every after_*, after_commit only on request.
1849
+ def run_order(callbacks, commits_in_order)
1850
+ ordered = callbacks.dup
1851
+ callbacks.each_index.group_by { |i| Listeners::CallbacksListener.chain_key(callbacks[i][:type]) }.each_value do |slots|
1852
+ chain = slots.each_with_object([]) do |i, list|
1853
+ prepended?(callbacks[i], commits_in_order) ? list.unshift(callbacks[i]) : list.push(callbacks[i])
1854
+ end
1855
+ chain.reverse! if Listeners::CallbacksListener.after?(callbacks[slots.first][:type])
1856
+ slots.zip(chain) { |i, cb| ordered[i] = cb }
1857
+ end
1858
+ ordered
1859
+ end
1860
+
1861
+ # An unknown setting keeps the transaction callbacks in declaration order.
1862
+ def prepended?(callback, commits_in_order)
1863
+ declared = callback.dig(:options, :prepend).to_s == "true"
1864
+ return declared unless Listeners::CallbacksListener.after?(callback[:type])
1865
+ return true unless Listeners::CallbacksListener.transaction?(callback[:type])
1866
+
1867
+ declared || commits_in_order != false
1868
+ end
1869
+
1870
+ # Rails 7.0 has no setting and always runs them last declared first.
1871
+ def booted_commits_in_order
1872
+ ActiveRecord.respond_to?(:run_after_transaction_callbacks_in_order_defined) &&
1873
+ ActiveRecord.run_after_transaction_callbacks_in_order_defined == true
1874
+ end
1875
+
1876
+ # nil when the config cannot say: no config/application.rb, a version or
1877
+ # a value that is not a literal. A chain order has no way to carry a condition.
1878
+ def static_commits_in_order
1879
+ setting = framework_setting(:run_after_transaction_callbacks_in_order_defined=, since: 7.1)
1880
+ return (setting.value unless setting.value.is_a?(String)) if setting.assigned
1881
+ return nil if !setting.read || setting.version == Float::INFINITY
1882
+
1883
+ setting.version.to_f >= 7.1
1884
+ end
1885
+
1886
+ # Only a chain with two of them can be out of order.
1887
+ def commit_order_unread(callbacks)
1888
+ return nil unless static_commits_in_order.nil?
1889
+
1890
+ chains = Array(callbacks).select { |cb| Listeners::CallbacksListener.transaction?(cb[:type]) }.map { |cb| Listeners::CallbacksListener.chain_key(cb[:type]) }
1891
+ true if chains.tally.values.any? { |count| count > 1 }
1475
1892
  end
1476
1893
 
1477
1894
  # Each base read with its concerns; a base's concern macro that sits in a
1478
1895
  # class method runs for the class that calls it, the child as often as
1479
1896
  # the base.
1480
1897
  def walk_bases(bases, calls)
1481
- Array(bases).map do |name, path|
1898
+ Array(bases).each_with_index.map do |(name, path), index|
1482
1899
  own = sti_base_source(path)
1483
- next [ name, nil ] if own.nil?
1484
-
1485
- own = own_body(own, name)
1486
- [ name, own, *merge_concern_macros(own, name, calls, file: path) ]
1900
+ [ name, own && walk_class(own_body(own, name), name, calls, file: path, rank: index + 1) ]
1487
1901
  end
1488
1902
  end
1489
1903
 
@@ -1503,7 +1917,9 @@ module RailsAiContext
1503
1917
  next
1504
1918
  end
1505
1919
 
1506
- data = merge_inherited(data, base.slice(*WALKED_KEYS))
1920
+ # A connection is declared on an abstract base, so the child says which one.
1921
+ macros = Array(base[:macros]).map { |m| m[:macro] == :connects_to ? m.merge(declared_in: name) : m }
1922
+ data = merge_inherited(data, base.slice(*WALKED_KEYS).merge(macros: macros))
1507
1923
  # A base's concerns are the child's too: the child's record already
1508
1924
  # carries what they declared, and its callbacks credit them by name.
1509
1925
  data[:mixins] = Array(data[:mixins]) | Array(own[:mixins])
@@ -1553,21 +1969,18 @@ module RailsAiContext
1553
1969
  def merge_inherited(mine, inherited)
1554
1970
  merged = mine.merge(inherited) { |_key, ours, theirs| Array(ours) + Array(theirs) }
1555
1971
  merged[:associations] = dedup(merged[:associations]) { |a| [ a[:type], a[:name] ] }
1556
- merged[:scopes] = dedup(merged[:scopes]) { |s| s[:name] }
1972
+ # Default scopes stack base first; a named scope the model redefines is its own.
1973
+ base_defaults, base_named = Array(inherited[:scopes]).partition { |s| s[:default] }
1974
+ own_defaults, own_named = Array(mine[:scopes]).partition { |s| s[:default] }
1975
+ merged[:scopes] = dedup(base_defaults + own_defaults + own_named + base_named) { |s| s[:default] ? [ s[:name], s[:body] ] : s[:name] }
1557
1976
  merged[:enums] = dedup(merged[:enums]) { |e| e[:name].to_s }
1558
1977
  # `encrypts :secret` on a base and again on the child is one macro, and
1559
1978
  # the consumers read it as a list of attributes. The key is the
1560
1979
  # declaration: the line it was read at differs between two files, and
1561
1980
  # the concern tag differs between two ways of reaching one file.
1562
- merged[:macros] = dedup(merged[:macros]) { |m| m.except(:from_concern, :location) }
1563
- # Rails keeps one entry for a symbol callback declared on a base and
1564
- # again on the child, and two validators for a validation declared
1565
- # twice, so these two are not deduped alike. Two blocks are two
1566
- # callbacks unless they are one line of one file read twice.
1567
- merged[:callbacks] = dedup(merged[:callbacks]) do |c|
1568
- block = c[:method].to_s == Listeners::CallbacksListener::INLINE_BLOCK
1569
- [ c[:type], c[:method].to_s, (block ? [ c[:from_concern], c[:location] ] : nil) ]
1570
- end
1981
+ # Bases first, the order Rails runs them: a child's `ignored_columns +=` adds to its base's list.
1982
+ merged[:macros] = dedup(Array(inherited[:macros]) + Array(mine[:macros])) { |m| m.except(:from_concern, :location, :owner) }
1983
+ merged[:callbacks] = Array(inherited[:callbacks]) + Array(mine[:callbacks])
1571
1984
  # One source line read twice is still one declaration: a concern the
1572
1985
  # model and one of its bases both include is walked once per class, and
1573
1986
  # `included do` runs once. Two validations really written twice differ
@@ -1576,6 +1989,16 @@ module RailsAiContext
1576
1989
  merged
1577
1990
  end
1578
1991
 
1992
+ SYMBOL_TARGET = /\A[a-z_]\w*[?!]?\z/
1993
+
1994
+ # Rails keeps the later of a symbol declared twice; two blocks stay two.
1995
+ def callback_chain(callbacks)
1996
+ keys = callbacks.each_with_index.map do |c, i|
1997
+ c[:method].to_s.match?(SYMBOL_TARGET) ? [ Listeners::CallbacksListener.chain_key(c[:type]), c[:method].to_s ] : i
1998
+ end
1999
+ keys.zip(callbacks).reverse.uniq(&:first).reverse.map(&:last)
2000
+ end
2001
+
1579
2002
  # The model's own declaration wins: it is the one whose options the
1580
2003
  # class actually runs with.
1581
2004
  def dedup(entries)
@@ -1628,24 +2051,31 @@ module RailsAiContext
1628
2051
  def mongoid_static_models
1629
2052
  entries = mongoid_candidates
1630
2053
  models = mongoid_model_names(entries)
1631
- entries.each_with_object({}) do |(class_name, entry), result|
1632
- if entry[:error]
1633
- result[class_name] = { error: entry[:error] }
1634
- next
1635
- end
1636
- next unless models.include?(class_name)
2054
+ done = {}
2055
+ entries.each_key { |class_name| mongoid_entry(class_name, entries, models, done) }
2056
+ entries.keys.select { |class_name| done[class_name] }.to_h { |class_name| [ class_name, done[class_name].except(:settled_callbacks) ] }
2057
+ end
1637
2058
 
1638
- result[class_name] = if entry[:source].include?("Mongoid::Document")
1639
- mongoid_model_details(entry[:source]).merge(file: relative_to_root(entry[:path]))
1640
- else
1641
- # This walk keeps no candidate hash, so an AR model in a
1642
- # hybrid app gets the table it assigns itself and the derived
1643
- # stem otherwise - no namespace prefix, no STI parent.
1644
- static_model_details(entry[:path], class_name, table_name: TableName.explicit(entry[:source], class_name, app.root))
1645
- end
1646
- rescue => e
1647
- result[class_name] = { error: e.message }
2059
+ # A subclass of a document is a document; its parent is read first.
2060
+ def mongoid_entry(class_name, entries, models, done)
2061
+ return done[class_name] if done.key?(class_name)
2062
+
2063
+ entry = entries[class_name]
2064
+ return done[class_name] = { error: entry[:error] } if entry[:error]
2065
+ return done[class_name] = nil unless models.include?(class_name)
2066
+
2067
+ done[class_name] = false
2068
+ parent_name = entry[:superclass] && SuperclassChain.resolve_in_scope(class_name, entry[:superclass], nesting: entry[:nesting]) { |q| q if models.include?(q) }
2069
+ parent = parent_name && mongoid_entry(parent_name, entries, models, done)
2070
+ done[class_name] = if entry[:source].include?("Mongoid::Document") || (parent.is_a?(Hash) && parent[:mongoid])
2071
+ mongoid_model_details(entry[:source], class_name, entry[:path], parent: parent && parent[:mongoid] ? [ parent_name, parent ] : nil)
2072
+ .merge(file: relative_to_root(entry[:path]))
2073
+ else
2074
+ # A hybrid app's AR model gets only its own or the derived table here: no namespace prefix, no STI parent.
2075
+ static_model_details(entry[:path], class_name, table_name: TableName.explicit(entry[:source], class_name, app.root))
1648
2076
  end
2077
+ rescue => e
2078
+ done[class_name] = { error: e.message }
1649
2079
  end
1650
2080
 
1651
2081
  def mongoid_candidates
@@ -1665,8 +2095,8 @@ module RailsAiContext
1665
2095
  class_name = DeclaredConstant.resolve(source, relative.camelize)
1666
2096
  next if found.key?(class_name) || config.excluded_models.include?(class_name)
1667
2097
 
1668
- declaration = DeclaredConstant.declaration_for(DeclaredConstant.declarations(source), class_name)
1669
- found[class_name] = { path: path, source: source, superclass: declaration&.superclass }
2098
+ declaration = DeclaredConstant.declaration_for(DeclaredConstant.declarations(source, path_name: relative.camelize), class_name)
2099
+ found[class_name] = { path: path, source: source, superclass: declaration&.superclass, nesting: declaration&.nesting }
1670
2100
  rescue => e
1671
2101
  found[relative.camelize] ||= { error: e.message }
1672
2102
  end
@@ -1684,7 +2114,7 @@ module RailsAiContext
1684
2114
  added = entries.select do |name, entry|
1685
2115
  parent = entry[:superclass]
1686
2116
  !names.include?(name) && parent &&
1687
- SuperclassChain.resolve_in_scope(name, parent) { |q| q if names.include?(q) }
2117
+ SuperclassChain.resolve_in_scope(name, parent, nesting: entry[:nesting]) { |q| q if names.include?(q) }
1688
2118
  end.keys
1689
2119
  break if added.empty?
1690
2120
 
@@ -1704,7 +2134,10 @@ module RailsAiContext
1704
2134
  # A path the read could not answer falls through to the path walk, which raises. A class
1705
2135
  # nested in the model's file includes for itself, not for the model.
1706
2136
  def own_body(data, class_name)
1707
- data.merge(mixins: ConcernMembership.own_mixins(data[:mixins], class_name))
2137
+ data.merge(mixins: ConcernMembership.owned_by(data[:mixins], class_name, root: app.root),
2138
+ callbacks: ConcernMembership.owned_by(data[:callbacks], class_name),
2139
+ macros: ConcernMembership.owned_by(data[:macros], class_name),
2140
+ scopes: ConcernMembership.owned_by(data[:scopes], class_name).map { |scope| scope.except(:owner) })
1708
2141
  end
1709
2142
 
1710
2143
  def source_walk(path)
@@ -1712,70 +2145,131 @@ module RailsAiContext
1712
2145
  source ? SourceIntrospector.walk_source(source) : SourceIntrospector.call(path)
1713
2146
  end
1714
2147
 
1715
- # Class methods the class and its bases call, read only when a concern asks: a macro
1716
- # inside `def acts_as_watchable` belongs to the models that call it.
1717
- #
1718
- # A concern's `included do` calls join the set too, so a base walked later sees them.
1719
- def class_calls(paths)
2148
+ # The class files' side of the lookup, read only when a concern asks: their class-body
2149
+ # calls and their own class methods, `alias_method` in `class << self` included.
2150
+ def singleton_lookup(classes)
1720
2151
  reader = lambda do
1721
- paths.compact.select { |path| readable_source?(path) }.each_with_object({}) do |path, found|
2152
+ ranks = {}
2153
+ defs = []
2154
+ readable = classes.each_with_index.select { |(_, path), _| path && readable_source?(path) }
2155
+ found = readable.each_with_object({}) do |((name, path), rank), into|
1722
2156
  source = model_source(path)
1723
- tree = (source ? AstCache.parse_string(source) : AstCache.parse(path)).value
1724
- ConcernMacros::Run.merge_calls(found, SourceIntrospector.calls_outside_methods(tree))
2157
+ scope = class_scope((source ? AstCache.parse_string(source) : AstCache.parse(path)).value, name)
2158
+ own = scope.select { |node| node.is_a?(Prism::CallNode) && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)) }
2159
+ .group_by { |node| node.name.to_s }
2160
+ own.each_value { |sites| sites.each { |site| ranks[site.__id__] = rank } }
2161
+ defs.concat(ConcernMacros::SingletonLookup.own_defs(scope, rank))
2162
+ ConcernMacros::Run.merge_calls(into, own)
1725
2163
  rescue StandardError, ScriptError => e
1726
2164
  # A file the walk cannot read calls nothing it can see.
1727
2165
  RailsAiContext.debug_fail(e, nil, label: "class calls of #{path}")
1728
2166
  end
2167
+ defs.concat(base_defs(classes).map(&:last))
2168
+ ConcernMacros::SingletonLookup::Read.new(found, ranks, defs, classes.size)
1729
2169
  end
1730
- ClassCalls.new(reader, {})
2170
+ ConcernMacros::SingletonLookup.new(reader)
1731
2171
  end
1732
2172
 
1733
- # The class files' own calls are read once; the included-block calls
1734
- # grow as the walks read concerns.
1735
- ClassCalls = Struct.new(:reader, :included) do
1736
- def call = ConcernMacros::Run.merge_calls(ConcernMacros::Run.merge_calls({}, own), included)
2173
+ # [node, definition] for each class method a base body outside app/models gives the class: one
2174
+ # reopening ApplicationRecord at that base's rank, the rest at the outermost; each after its base's file.
2175
+ def base_defs(classes)
2176
+ application = classes.index { |name, _| name == "ApplicationRecord" }
2177
+ BaseMixins.bodies(app.root.to_s).flat_map do |body|
2178
+ rank = body.target == "ApplicationRecord" ? application : classes.size
2179
+ next [] unless rank
1737
2180
 
1738
- def own
1739
- @own ||= reader.call
2181
+ ConcernMacros::SingletonLookup.member_defs(body.statements, rank, body.order)
1740
2182
  end
1741
2183
  end
1742
2184
 
2185
+ # The nodes the class body runs with the class as self: the node opening a
2186
+ # method, nested class or `class << x` is there, its body is not.
2187
+ def class_scope(tree, class_name)
2188
+ short = class_name.to_s.split("::").last.to_s
2189
+ roots = AstWalk.each(tree).select { |node| node.is_a?(Prism::ClassNode) && node.constant_path.slice.split("::").last.casecmp?(short) }
2190
+ roots.empty? ? AstWalk.scope(tree) : roots.flat_map { |root| AstWalk.scope(root.body) }
2191
+ end
1743
2192
 
1744
- def mongoid_model_details(source)
2193
+ MONGOID_MACROS = -> { Listeners::GenericMacroListener.new(%i[field embeds_many embeds_one embedded_in store_in index], call_source: %i[index]) }
2194
+
2195
+ def mongoid_model_details(source, class_name, path, parent: nil)
1745
2196
  data = SourceIntrospector.walk_source(source, {
1746
- mongoid: -> { Listeners::GenericMacroListener.new(%i[field embeds_many embeds_one embedded_in store_in]) },
2197
+ mongoid: MONGOID_MACROS,
1747
2198
  associations: Listeners::AssociationsListener,
1748
2199
  validations: Listeners::ValidationsListener,
1749
2200
  scopes: Listeners::ScopesListener,
1750
2201
  callbacks: Listeners::CallbacksListener,
1751
- methods: Listeners::MethodsListener
2202
+ methods: Listeners::MethodsListener,
2203
+ mixins: Listeners::MixinsListener
1752
2204
  })
1753
- macros = data[:mongoid] || []
2205
+ # A concern's `included` block declares into the document before the body that follows it.
2206
+ from_concerns = ConcernMacros.collect(app.root.to_s, data[:mixins], keys: %i[mongoid], prefer: "model", within: class_name,
2207
+ listeners: { mongoid: MONGOID_MACROS, mixins: Listeners::MixinsListener }, file: path).collected
2208
+ macros = Array(from_concerns[:mongoid]) + Array(data[:mongoid])
2209
+ calls = singleton_lookup([ [ class_name, path ] ])
2210
+ calls.add(0, {}, {}, [])
2211
+ own = own_body(data.merge(mixins: []), class_name)
2212
+ parent_name, inherited = parent
2213
+ settled = settle(Walk.empty(own), calls).first[:callbacks]
2214
+ # A parent's callbacks join the chain as a base's do, one rank above the subclass's.
2215
+ settled = Array(inherited&.dig(:settled_callbacks)).map { |cb| cb.merge(rank: cb[:rank].to_i + 1) } + settled
2216
+ # Mongoid sets after_commit without prepend, as Rails 7.0 does, so it runs last declared first.
2217
+ callbacks = chain_order(settled, false)
1754
2218
  details = {
1755
2219
  confidence: Confidence::STATIC,
1756
2220
  mongoid: true,
1757
- fields: macros.select { |m| m[:macro] == :field }
1758
- .map { |m| { name: m[:args].first, type: m[:options][:type] }.compact },
2221
+ fields: macros.select { |m| m[:macro] == :field }.map { |m| mongoid_field(m) },
1759
2222
  embeds: macros.select { |m| %i[embeds_many embeds_one embedded_in].include?(m[:macro]) }
1760
2223
  .map { |m| { type: m[:macro], name: m[:args].first } },
2224
+ indexes: macros.select { |m| m[:macro] == :index }.map { |m| m[:text] },
1761
2225
  # An embedded child is a relation like any other, so every count and the graph see it.
1762
2226
  associations: reject_excluded_associations(Array(data[:associations]) + embedded_associations(macros)),
1763
2227
  validations: data[:validations],
1764
- scopes: data[:scopes],
2228
+ scopes: own[:scopes],
1765
2229
  # Same shape as the booted tier: a Hash keyed by callback type. The
1766
2230
  # listener hands back a flat Array, and every consumer filters on
1767
2231
  # `callbacks.is_a?(Hash)` - so passing it through rendered "No models
1768
2232
  # with callbacks found" and then raised a TypeError on a Hash lookup
1769
2233
  # against an Array.
1770
- callbacks: group_callbacks_by_type(data[:callbacks]),
1771
- callback_conditions: callback_conditions(data[:callbacks]),
1772
- methods: data[:methods]
2234
+ callbacks: group_callbacks_by_type(callbacks),
2235
+ callback_conditions: callback_conditions(callbacks),
2236
+ methods: data[:methods],
2237
+ settled_callbacks: settled
1773
2238
  }
1774
- collection = macros.find { |m| m[:macro] == :store_in }&.dig(:options, :collection)
1775
- details[:collection] = collection if collection
2239
+ if inherited
2240
+ { fields: :name, embeds: :name, associations: :name, scopes: :name }.each do |key, name|
2241
+ mine = Array(details[key]).map { |entry| entry[name].to_s }
2242
+ details[key] = Array(inherited[key]).reject { |entry| mine.include?(entry[name].to_s) } + Array(details[key])
2243
+ end
2244
+ details[:validations] = (Array(inherited[:validations]) + Array(details[:validations])).uniq
2245
+ details[:parent_model] = parent_name
2246
+ end
2247
+ embedded = macros.find { |m| m[:macro] == :embedded_in }
2248
+ embedded_in = embedded ? embedded_parent(embedded) : inherited&.dig(:embedded_in)
2249
+ if embedded_in
2250
+ details[:embedded_in] = embedded_in
2251
+ else
2252
+ # Mongoid's collection_name: store_in's, else the root class's name tableized with "/" as "_".
2253
+ details[:collection] = macros.find { |m| m[:macro] == :store_in }&.dig(:options, :collection) ||
2254
+ inherited&.dig(:collection) || class_name.tableize.tr("/", "_")
2255
+ end
1776
2256
  downgrade_records(details)
1777
2257
  end
1778
2258
 
2259
+ # A default Mongoid computes (a lambda) is left out, as the replay leaves a computed column default.
2260
+ def mongoid_field(macro)
2261
+ options = macro[:options]
2262
+ default = SchemaConventions.format_default(options[:default]) unless options[:default] == Confidence::INFERRED
2263
+ { name: macro[:args].first, type: options[:type], default: default }.compact
2264
+ end
2265
+
2266
+ def embedded_parent(macro)
2267
+ options = macro[:options] || {}
2268
+ return "#{macro[:args].first} (polymorphic)" if options[:polymorphic]
2269
+
2270
+ options[:class_name]&.to_s || macro[:args].first.to_s.camelize
2271
+ end
2272
+
1779
2273
  def embedded_associations(macros)
1780
2274
  macros.select { |m| %i[embeds_many embeds_one].include?(m[:macro]) }.map do |m|
1781
2275
  { name: m[:args].first.to_s, type: m[:macro].to_s, class_name: m[:options][:class_name]&.to_s }.compact