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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
4
+
3
5
  module RailsAiContext
4
6
  module Tools
5
7
  class AnalyzeFeature < BaseTool
@@ -58,6 +60,7 @@ module RailsAiContext
58
60
  discover_controllers(ctx, pattern, lines)
59
61
  discover_routes(ctx, pattern, lines)
60
62
  discover_services(root, pattern, lines)
63
+ discover_admin(root, pattern, lines)
61
64
  discover_jobs(ctx, pattern, lines)
62
65
  discover_views(ctx, root, pattern, lines)
63
66
  discover_stimulus(ctx, pattern, lines)
@@ -126,6 +129,15 @@ module RailsAiContext
126
129
  end
127
130
  end
128
131
 
132
+ def discover_admin(root, pattern, lines)
133
+ matched = Introspectors::AdminResources.call(root).select { |r| feature_word_match?(r[:model], pattern) }
134
+ return if matched.empty?
135
+
136
+ lines << "## Admin Resources (#{matched.size})"
137
+ matched.each { |resource| lines << "- #{Introspectors::AdminResources.line(resource)}" }
138
+ lines << ""
139
+ end
140
+
129
141
  # --- AF: Models ---
130
142
  def discover_models(ctx, pattern, lines)
131
143
  models = Payload.models(ctx)
@@ -147,11 +159,10 @@ module RailsAiContext
147
159
  lines << "**Table:** `#{data[:table_name]}`" if data[:table_name]
148
160
 
149
161
  table_name = data[:table_name]
150
- if table_name && (tables = ctx.dig(:schema, :tables))
151
- table_data = tables[table_name]
162
+ if table_name && (table_data = RailsAiContext::Payload.model_table(ctx[:schema], data))
152
163
  if table_data&.dig(:columns)&.any?
153
164
  cols = table_data[:columns].reject { |c| %w[id created_at updated_at].include?(c[:name]) }
154
- col_strs = cols.map { |c| col_type = c[:array] ? "#{c[:type]}[]" : c[:type]; "#{c[:name]}:#{col_type}" }
165
+ col_strs = cols.map { |c| col_type = c[:array] ? "#{c[:type]}[]" : c[:type]; [ c[:name], col_type ].compact.join(":") }
155
166
  lines << "**Columns:** #{col_strs.join(', ')}" if cols.any?
156
167
  end
157
168
  end
@@ -376,26 +387,27 @@ module RailsAiContext
376
387
  end
377
388
 
378
389
  def discover_tests(root, pattern, lines)
379
- test_dirs = [ File.join(root, "spec"), File.join(root, "test") ]
380
390
  real_root = File.realpath(root).to_s
381
391
  found = []
382
392
  truncated = false
383
393
 
384
- test_dirs.each do |dir|
394
+ base = RailsAiContext::PathResolver.test_root(root)
395
+ real_base = File.realpath(base).to_s
396
+ [ File.join(base, "spec"), File.join(base, "test") ].each do |dir|
385
397
  next unless Dir.exist?(dir)
386
- suffix_glob = safe_glob(dir, "**/*_{test,spec}.rb", real_root).first(MAX_SCAN_FILES)
398
+ suffix_glob = safe_glob(dir, "**/*_{test,spec}.rb", real_base).first(MAX_SCAN_FILES)
387
399
  truncated = true if suffix_glob.size == MAX_SCAN_FILES
388
400
  # The path, not the basename: the gap checker below matches this
389
401
  # way for the same reason, and a spec whose feature word is a
390
402
  # directory (billing/invoices/create_spec.rb) is the normal shape
391
403
  # of a namespaced suite.
392
404
  suffix_glob.each do |path|
393
- found << path if feature_word_match?(relative_test_name(path, real_root), pattern)
405
+ found << path if feature_word_match?(relative_test_name(path, real_base), pattern)
394
406
  end
395
- prefix_glob = safe_glob(dir, "**/{test,spec}_*.rb", real_root).first(MAX_SCAN_FILES)
407
+ prefix_glob = safe_glob(dir, "**/{test,spec}_*.rb", real_base).first(MAX_SCAN_FILES)
396
408
  truncated = true if prefix_glob.size == MAX_SCAN_FILES
397
409
  prefix_glob.each do |path|
398
- found << path if feature_word_match?(relative_test_name(path, real_root), pattern)
410
+ found << path if feature_word_match?(relative_test_name(path, real_base), pattern)
399
411
  end
400
412
  end
401
413
  found.uniq!
@@ -404,7 +416,7 @@ module RailsAiContext
404
416
  header = "## Tests (#{found.size}#{truncated ? " - first #{MAX_SCAN_FILES} per glob scanned" : ""})"
405
417
  lines << header
406
418
  found.each do |path|
407
- relative = path.sub("#{real_root}/", "")
419
+ relative = Pathname.new(path).relative_path_from(Pathname.new(real_root)).to_s
408
420
  source = RailsAiContext::SafeFile.read(path) or next
409
421
  test_count = source.each_line.count do |line|
410
422
  line.match?(/^\s*(?:test|it|specify)\s+["']/) || line.match?(/^\s*def\s+test_/)
@@ -133,7 +133,13 @@ module RailsAiContext
133
133
  # Cache introspection results with TTL + fingerprint invalidation.
134
134
  # Uses SHARED_CACHE so all tool subclasses share one introspection
135
135
  # result instead of each caching independently.
136
+ # One copy per tool call: a tool reads the context from many helpers,
137
+ # and each copy deep-dups every section.
136
138
  def cached_context
139
+ RunCache.fetch([ :tool_context ]) { shared_context_copy }
140
+ end
141
+
142
+ private def shared_context_copy
137
143
  SHARED_CACHE[:mutex].synchronize do
138
144
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
139
145
  ttl = RailsAiContext.configuration.cache_ttl
@@ -182,7 +188,6 @@ module RailsAiContext
182
188
  session_reset!
183
189
  AstCache.clear
184
190
  PathResolver.clear_code_roots
185
- Introspectors::TableName.clear_namespace_prefixes
186
191
  end
187
192
 
188
193
  # ── Session context helpers ──────────────────────────────────────
@@ -299,7 +304,7 @@ module RailsAiContext
299
304
 
300
305
  # Structured not-found error with fuzzy suggestion and recovery hint.
301
306
  # Helps AI agents self-correct without retrying blind.
302
- def not_found_response(type, name, available, recovery_tool: nil)
307
+ def not_found_response(type, name, available, recovery_tool: nil, note: nil)
303
308
  # Don't suggest the exact same string the user typed - that's useless
304
309
  suggestions = find_closest_matches(name, available) - [ name ]
305
310
  lines = [ "#{type} '#{name}' not found." ]
@@ -310,9 +315,18 @@ module RailsAiContext
310
315
  end
311
316
  lines << "Available: #{available.first(20).join(', ')}#{"..." if available.size > 20}" if available.any?
312
317
  lines << "_Recovery: #{recovery_tool}_" if recovery_tool
318
+ lines << "" << note if note
313
319
  empty_response(lines.join("\n"))
314
320
  end
315
321
 
322
+ # Unbooted, a test/dummy's enclosing engine is not a views root.
323
+ def static_engine_views_note
324
+ root = rails_app.root.to_s
325
+ return nil unless RailsAiContext.static_tier? && RailsAiContext::PathResolver.test_root(root) != root
326
+
327
+ "_The views of the engine this app runs in are read only with the app booted._"
328
+ end
329
+
316
330
  # A tool ran, answered honestly, and found nothing. Renders exactly
317
331
  # like text_response - the mark rides in `_meta`, where a composing
318
332
  # tool can read it and a reader never sees it.
@@ -438,8 +452,10 @@ module RailsAiContext
438
452
  # an API-only app, or nil when the app has a view layer. Tools check
439
453
  # this before rendering "no X found" copy so a legitimately absent
440
454
  # surface never reads as "not built yet".
441
- def api_only_note(section_label)
455
+ def api_only_note(section_label, dir: nil)
442
456
  return nil unless api_only_app?
457
+ # rails new --api keeps app/views when it keeps Action Mailer.
458
+ return nil if dir && File.directory?(File.join(rails_app.root.to_s, dir))
443
459
 
444
460
  "Not applicable: this is an API-only app (config.api_only), so #{section_label} does not exist."
445
461
  end
@@ -594,26 +610,17 @@ module RailsAiContext
594
610
  pattern.match?(/\w\z/) ? "\\b" : ""
595
611
  end
596
612
 
597
- # Extract method source from a source string via indentation-based matching.
598
- # Returns { code:, start_line:, end_line: } or nil. Shared by get_callbacks, get_concern.
613
+ # "self.x" asks for the class method; the file's own class answers before a nested
614
+ # module (a concern's ClassMethods), which answers only when the class has none.
599
615
  def extract_method_source_from_string(source, method_name)
600
- source_lines = source.lines
601
616
  name = method_name.to_s
602
- pattern = /\A\s*def\s+#{Regexp.escape(name)}#{trailing_boundary(name)}/
603
- start_idx = source_lines.index { |l| l.match?(pattern) }
604
- return nil unless start_idx
605
-
606
- def_indent = source_lines[start_idx][/\A\s*/].length
607
- result = []
608
- end_idx = start_idx
609
-
610
- source_lines[start_idx..].each_with_index do |line, i|
611
- result << line.rstrip
612
- end_idx = start_idx + i
613
- break if i > 0 && line.match?(/\A\s{#{def_indent}}end\b/)
614
- end
615
-
616
- { code: result.join("\n"), start_line: start_idx + 1, end_line: end_idx + 1 }
617
+ scope = name.start_with?("self.") ? :class : :instance
618
+ bare = name.delete_prefix("self.")
619
+ resolver = Introspectors::ActionResolver
620
+ methods = resolver.methods_in(source)
621
+ named = methods.select { |m| m[:name] == bare && m[:scope] == scope }
622
+ method = resolver.own_methods(named, resolver.default_owner(source, methods)).first || named.first
623
+ method && resolver.body_of(source, method)
617
624
  rescue => e
618
625
  RailsAiContext.debug_fail(e, nil, label: "extract_method_source_from_string")
619
626
  end
@@ -636,15 +643,16 @@ module RailsAiContext
636
643
  return named if named
637
644
  end
638
645
 
639
- fixture_file = File.join(rails_app.root, "test", "fixtures", "#{table}.yml")
646
+ real_root = File.realpath(rails_app.root.to_s)
647
+ fixture_file = File.join(real_root, "test", "fixtures", "#{table}.yml")
640
648
  return nil unless File.exist?(fixture_file)
641
649
 
642
- # The disk read keeps its own filter: it reads raw YAML that no
643
- # introspector has been through.
644
- content = RailsAiContext::SafeFile.read(fixture_file)
645
- content&.scan(/^([a-z_]\w*):/i)
646
- &.flatten
647
- &.find { |key| RailsAiContext::FixtureKeys.name?(key) }
650
+ real = safe_glob_realpath(fixture_file, real_root, real_root) or return nil
651
+ content = RailsAiContext::SafeFile.read(real) or return nil
652
+ labels = RailsAiContext::FixtureKeys.parse(content)&.keys || content.scan(/^([a-z_]\w*):/i).flatten
653
+ labels.find { |key| RailsAiContext::FixtureKeys.name?(key) }
654
+ rescue SystemCallError => e
655
+ RailsAiContext.debug_fail(e, nil, label: "fixture_key_for")
648
656
  end
649
657
 
650
658
  # Fixture set from `set_fixture_class`, then the table name, then the pluralized class
@@ -704,8 +712,9 @@ module RailsAiContext
704
712
  method.to_s == RailsAiContext::Introspectors::Listeners::CallbacksListener::INLINE_BLOCK
705
713
  end
706
714
 
715
+ # A constant (`AuditTrail`) is a callback object, not a method.
707
716
  def method_name?(method)
708
- method.to_s.match?(/\A\w+[?!=]?\z/)
717
+ method.to_s.match?(/\A[a-z_]\w*[?!=]?\z/)
709
718
  end
710
719
 
711
720
  # A callback type as a reader can write it. `after_commit_on_create`
@@ -713,14 +722,28 @@ module RailsAiContext
713
722
  # `after_commit on: [...]`; Rails has no macro by that name, so an
714
723
  # agent that copies it gets a NoMethodError.
715
724
  def callback_type_label(type)
716
- event = type.to_s[/\Aafter_commit_on_(\w+)\z/, 1]
725
+ event = RailsAiContext::Introspectors::Listeners::CallbacksListener.event(type)
717
726
  event ? "after_commit (on: :#{event})" : type.to_s
718
727
  end
719
728
 
729
+ # Statically, a config that does not say how Rails orders after_commit
730
+ # and after_rollback leaves those lists in declaration order.
731
+ def commit_order_note(unread)
732
+ return nil if unread.empty?
733
+
734
+ "_after_commit and after_rollback for #{unread.sort.join(', ')} are in declaration order: the config does not say " \
735
+ "whether `run_after_transaction_callbacks_in_order_defined` is on, and when it is off Rails runs them last declared first._"
736
+ end
737
+
720
738
  # One callback record rendered as the line the file declares. The
721
739
  # declared macro, not the resolved type: `after_commit_on_create` is
722
740
  # a key this gem synthesizes, not something the source says.
723
741
  def callback_declaration(callback)
742
+ if callback[:skip]
743
+ kind, event = callback[:type].to_s.split("_", 2)
744
+ return "skip_callback :#{event}, :#{kind}, #{callback_target(callback[:method])}#{callback_options_tail(callback[:options])}"
745
+ end
746
+
724
747
  name = callback[:name] || callback[:type]
725
748
  method = callback[:method].to_s
726
749
  target = inline_block_callback?(method) ? "do" : callback_target(method)
@@ -841,6 +864,22 @@ module RailsAiContext
841
864
 
842
865
  private
843
866
 
867
+ # The locale and variant a view file renders for, reading the locales the app makes available.
868
+ def view_alternate_of(path)
869
+ RailsAiContext::ViewFile.alternate_of(path, RailsAiContext::RunCache.fetch([ :view_locales ]) { available_locales })
870
+ end
871
+
872
+ def booted_app?
873
+ !RailsAiContext.static_tier? && !rails_app.is_a?(RailsAiContext::StaticApp)
874
+ end
875
+
876
+ # Booted, as I18n holds them; unbooted, as the i18n section read them from config or the locale files.
877
+ def available_locales
878
+ return I18n.available_locales.map(&:to_s) if booted_app? && defined?(I18n)
879
+
880
+ Array(RailsAiContext::Payload.section(cached_context, :i18n)&.dig(:available_locales)).map(&:to_s)
881
+ end
882
+
844
883
  # English units whatever the locale, unless the app offers no English at all.
845
884
  def human_size(bytes)
846
885
  ActiveSupport::NumberHelper.number_to_human_size(bytes.to_i, locale: :en)
@@ -363,7 +363,7 @@ module RailsAiContext
363
363
  names += Array(model_data[:scopes]).filter_map { |s| s.is_a?(Hash) ? (s[:name] || s["name"])&.to_s : s.to_s }
364
364
 
365
365
  table = model_data[:table_name].to_s
366
- columns = cached_context.dig(:schema, :tables, table, :columns)
366
+ columns = RailsAiContext::Payload.schema_table(cached_context[:schema], table)&.dig(:columns)
367
367
  names += Array(columns).filter_map { |c| (c[:name] || c["name"])&.to_s }
368
368
 
369
369
  names.uniq
@@ -121,7 +121,7 @@ module RailsAiContext
121
121
  # the subject's existing test, so the answer can point there instead of a new file.
122
122
  def test_path(base, kind, stem, fallback_dir, fallback_suffix)
123
123
  framework = RailsAiContext::TestFramework
124
- root = rails_app.root.to_s
124
+ root = RailsAiContext::PathResolver.test_root(rails_app.root.to_s)
125
125
  dir, suffix = framework.layout(root, base, kind, framework.subject_stems(cached_context, kind))
126
126
  dir ||= fallback_dir
127
127
  suffix ||= fallback_suffix
@@ -131,7 +131,9 @@ module RailsAiContext
131
131
  full = File.expand_path(rel, root)
132
132
  RailsAiContext::SafePath.contained?(full, root) && File.file?(full)
133
133
  end
134
- [ "#{dir}/#{stem}#{suffix}", existing, dir ]
134
+ app_root = rails_app.root.to_s
135
+ [ RailsAiContext::PathResolver.suite_relative(app_root, "#{dir}/#{stem}#{suffix}"),
136
+ existing && RailsAiContext::PathResolver.suite_relative(app_root, existing), dir ]
135
137
  rescue => e
136
138
  RailsAiContext.debug_fail(e, [ "#{fallback_dir}/#{stem}#{fallback_suffix}", nil, fallback_dir ], label: "test_path")
137
139
  end
@@ -643,7 +645,7 @@ module RailsAiContext
643
645
  file_path, existing, dir = test_path("test", :controller, snake, "test/controllers", "_controller_test.rb")
644
646
  return existing_test_response(existing) if existing
645
647
 
646
- style = RailsAiContext::TestFramework.test_style(rails_app.root, dir)
648
+ style = RailsAiContext::TestFramework.test_style(RailsAiContext::PathResolver.test_root(rails_app.root.to_s), dir)
647
649
  res = res.merge(test_case: style[:test_case] == true)
648
650
  res[:factory] = find_factory_name(res[:model], tests_data) if style[:factories] && res[:model]
649
651
  res[:parents] = route_parents(routes, res, tests_data, ref: "@") if res[:factory]
@@ -1392,14 +1394,12 @@ module RailsAiContext
1392
1394
  end
1393
1395
 
1394
1396
  def schema_content_columns(table)
1395
- tables = (cached_context[:schema] || {})[:tables] || {}
1396
- cols = (tables[table] || tables[table.to_sym] || {})[:columns] || []
1397
+ cols = (RailsAiContext::Payload.schema_table(cached_context[:schema], table) || {})[:columns] || []
1397
1398
  cols.map { |c| c[:name].to_s } - %w[id created_at updated_at]
1398
1399
  end
1399
1400
 
1400
1401
  def schema_column_type(table, column)
1401
- tables = (cached_context[:schema] || {})[:tables] || {}
1402
- cols = (tables[table] || tables[table.to_sym] || {})[:columns] || []
1402
+ cols = (RailsAiContext::Payload.schema_table(cached_context[:schema], table) || {})[:columns] || []
1403
1403
  col = cols.find { |c| c[:name].to_s == column }
1404
1404
  (col && col[:type]).to_s
1405
1405
  end
@@ -1408,8 +1408,7 @@ module RailsAiContext
1408
1408
  # own value for one of these raises RecordNotUnique even when the
1409
1409
  # model declares no uniqueness validation.
1410
1410
  def unique_index_columns(table)
1411
- tables = (cached_context[:schema] || {})[:tables] || {}
1412
- indexes = (tables[table] || tables[table.to_sym] || {})[:indexes] || []
1411
+ indexes = (RailsAiContext::Payload.schema_table(cached_context[:schema], table) || {})[:indexes] || []
1413
1412
  indexes.select { |i| i[:unique] }.flat_map { |i| Array(i[:columns]).map(&:to_s) }
1414
1413
  end
1415
1414
 
@@ -4,7 +4,7 @@ module RailsAiContext
4
4
  module Tools
5
5
  class GetActiveSupport < BaseTool
6
6
  tool_name "rails_get_active_support"
7
- description "Get ActiveSupport surface: concern modules across app/**/concerns, registered deprecators, MessageVerifier/MessageEncryptor usage, tagged logging, subscribed on_load hooks, and cache store. " \
7
+ description "Get ActiveSupport surface: concern modules across app/**/concerns, registered deprecators, MessageVerifier/MessageEncryptor usage, the ActiveSupport::Notifications events the app subscribes to and where, tagged logging, subscribed on_load hooks, and cache store. " \
8
8
  "Use when: checking framework-level wiring, finding where crypto helpers are used, or understanding boot-time hooks."
9
9
 
10
10
  input_schema(properties: {})
@@ -12,7 +12,7 @@ module RailsAiContext
12
12
  guide_row(
13
13
  order: 44,
14
14
  mcp: "rails_get_active_support",
15
- summary: "Concerns registry, deprecators, MessageVerifier usage, on_load hooks, cache store"
15
+ summary: "Concerns registry, deprecators, MessageVerifier usage, notification subscriptions, on_load hooks, cache store"
16
16
  )
17
17
 
18
18
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
@@ -24,6 +24,7 @@ module RailsAiContext
24
24
  render_concerns(lines, active_support[:concerns])
25
25
  render_simple_list(lines, "Deprecators", active_support[:deprecators])
26
26
  render_verifier_usage(lines, active_support[:message_verifier_usage])
27
+ render_subscriptions(lines, active_support[:notification_subscriptions])
27
28
  render_tagged_logging(lines, active_support[:tagged_logging])
28
29
  render_on_load_hooks(lines, active_support[:on_load_hooks])
29
30
  render_cache(lines, active_support[:cache_usage])
@@ -109,12 +110,26 @@ module RailsAiContext
109
110
  end
110
111
  end
111
112
 
113
+ def render_subscriptions(lines, subscriptions)
114
+ subscriptions = Array(subscriptions)
115
+ return if subscriptions.empty?
116
+
117
+ lines << "" << "## Notification Subscriptions (#{subscriptions.size})"
118
+ subscriptions.each { |s| lines << "- `#{s[:event]}` - #{s[:via]} (`#{s[:file]}:#{s[:line]}`)" }
119
+ end
120
+
112
121
  def render_tagged_logging(lines, tagged)
113
122
  tagged = tagged || {}
114
- return unless tagged[:configured]
123
+ tags = tagged[:tags]
124
+ unavailable = tags[:unavailable] if tags.is_a?(Hash)
125
+ return unless tagged[:configured] || unavailable
115
126
 
116
127
  lines << "" << "## Tagged Logging"
117
- lines << "- **Tags:** #{Array(tagged[:tags]).join(', ')}" if tagged[:tags]&.any?
128
+ if unavailable
129
+ lines << "- **Tags:** #{RailsAiContext::Confidence.unavailable(unavailable)}"
130
+ elsif tags&.any?
131
+ lines << "- **Tags:** #{Array(tags).join(', ')}"
132
+ end
118
133
  lines << "- **Configured in:** `#{tagged[:initializer]}`" if tagged[:initializer]
119
134
  end
120
135
 
@@ -52,6 +52,17 @@ module RailsAiContext
52
52
  end
53
53
  end
54
54
 
55
+ # What serializes responses, or nil when the app has no serializer layer.
56
+ def self.serialization_found(data)
57
+ return serialization_label(data[:serializers]) if serializers?(data)
58
+
59
+ dirs = Array(data.dig(:serializers, :serializer_dirs))
60
+ return if dirs.empty?
61
+
62
+ "no serializer framework detected; the app has its own layer in " +
63
+ dirs.map { |dir| "#{dir[:path]} (#{count_phrase(dir[:files], "file")})" }.join(", ")
64
+ end
65
+
55
66
  class << self
56
67
  private
57
68
 
@@ -103,7 +114,7 @@ module RailsAiContext
103
114
  source = dirs.any? ? dirs.join(", ") : "app/controllers/api/"
104
115
  return "#{data[:api_versioning].join(', ')} (#{source})"
105
116
  end
106
- "not detected (no api/v* directories under app/controllers, app/api or lib/api)"
117
+ "not detected (no api/v* directory with Ruby code under app/controllers, app/api or lib/api)"
107
118
  end
108
119
 
109
120
  def rate_limiting_line(data)
@@ -171,7 +182,7 @@ module RailsAiContext
171
182
  def serializers?(data)
172
183
  serializers = data[:serializers]
173
184
  return false unless serializers.is_a?(Hash)
174
- serializers[:jbuilder].to_i > 0 || (serializers[:serializer_classes].is_a?(Array) && serializers[:serializer_classes].any?)
185
+ serializers[:jbuilder].to_i > 0 || serializers[:rabl].to_i > 0 || (serializers[:serializer_classes].is_a?(Array) && serializers[:serializer_classes].any?)
175
186
  end
176
187
 
177
188
  def versioning?(data)
@@ -186,6 +197,8 @@ module RailsAiContext
186
197
  parts = []
187
198
  jbuilder = serializers[:jbuilder].to_i
188
199
  parts << "Jbuilder (#{count_phrase(jbuilder, "template")})" if jbuilder > 0
200
+ rabl = serializers[:rabl].to_i
201
+ parts << "RABL (#{count_phrase(rabl, "template")})" if rabl > 0
189
202
 
190
203
  classes = serializers[:serializer_classes]
191
204
  if classes.is_a?(Array) && classes.any?
@@ -198,15 +211,8 @@ module RailsAiContext
198
211
  end
199
212
 
200
213
  def serialization_line(data)
201
- return serialization_label(data[:serializers]) if serializers?(data)
202
-
203
- dirs = Array(data.dig(:serializers, :serializer_dirs))
204
- if dirs.any?
205
- return "no serializer framework detected; the app has its own layer in " +
206
- dirs.map { |dir| "#{dir[:path]} (#{count_phrase(dir[:files], "file")})" }.join(", ")
207
- end
208
-
209
- "none detected (no .jbuilder templates, no app/serializers classes)"
214
+ serialization_found(data) ||
215
+ "none detected (no .jbuilder or .rabl templates, no serializer classes in app/serializers, app/blueprints or app/resources)"
210
216
  end
211
217
 
212
218
  def graphql_label(graphql)
@@ -36,12 +36,14 @@ module RailsAiContext
36
36
  lines << " - [error: #{l[:error]}]" if l[:error]
37
37
  lines << " - collapsed: #{l[:collapsed].join(', ')}" if l[:collapsed]&.any?
38
38
  lines << " - ignored: #{l[:ignored].join(', ')}" if l[:ignored]&.any?
39
+ lines << " - not eager loaded: #{l[:not_eager_loaded].join(', ')}" if l[:not_eager_loaded]&.any?
39
40
  end
40
41
  end
41
42
 
42
- render_paths(lines, "Autoload paths", autoload[:autoload_paths])
43
- render_paths(lines, "Autoload-once paths", autoload[:autoload_once_paths])
44
- render_paths(lines, "Eager-load paths", autoload[:eager_load_paths])
43
+ namespaces = autoload[:autoload_namespaces] || {}
44
+ render_paths(lines, "Autoload paths", autoload[:autoload_paths], namespaces)
45
+ render_paths(lines, "Autoload-once paths", autoload[:autoload_once_paths], namespaces)
46
+ render_paths(lines, "Eager-load paths", autoload[:eager_load_paths], namespaces)
45
47
 
46
48
  inflections = autoload[:custom_inflections] || []
47
49
  if inflections.any?
@@ -59,12 +61,15 @@ module RailsAiContext
59
61
  # Custom autoload paths (beyond Rails' defaults) matter most, but the
60
62
  # full list is what AI needs for "where can this constant live" - cap
61
63
  # long framework lists rather than hiding them.
62
- def render_paths(lines, title, paths)
64
+ def render_paths(lines, title, paths, namespaces)
63
65
  paths = Array(paths)
64
66
  return if paths.empty?
65
67
 
66
68
  lines << "" << "## #{title} (#{paths.size})"
67
- paths.first(MAX_PATHS_SHOWN).each { |p| lines << "- `#{p}`" }
69
+ paths.first(MAX_PATHS_SHOWN).each do |p|
70
+ namespace = namespaces[p] || namespaces[p.to_sym]
71
+ lines << "- `#{p}`#{" (namespace `#{namespace}`)" if namespace}"
72
+ end
68
73
  lines << "_... #{paths.size - MAX_PATHS_SHOWN} more_" if paths.size > MAX_PATHS_SHOWN
69
74
  end
70
75
  end
@@ -7,7 +7,8 @@ module RailsAiContext
7
7
  description "Get ActiveRecord model callbacks grouped by type, in Rails event order: before/after/around for validation, save, create, update, destroy. " \
8
8
  "Use when: understanding side effects, debugging callback chains, or checking what happens on save/create/destroy. " \
9
9
  "Specify model:\"User\" for one model's callbacks. detail:\"full\" includes callback method source code. " \
10
- "The list is what the model file and its concerns declare, and within one type the order is declaration order."
10
+ "The list is what the model, its base classes and its concerns declare, and within one type the order is the order Rails runs them, " \
11
+ "a before_ or around_ callback with prepend: true first, and after_commit in the order the app's setting gives."
11
12
 
12
13
  # `after_create_commit :x` and `after_commit :y, on: :create` run at the
13
14
  # same point and keep their own declared spellings, so the two sit
@@ -87,11 +88,18 @@ module RailsAiContext
87
88
 
88
89
  private_class_method def self.format_model_callbacks(name, data, detail, ctx)
89
90
  callbacks = data[:callbacks] || {}
90
- if callbacks.empty?
91
+ association_lines = association_callback_lines(data)
92
+ if callbacks.empty? && association_lines.empty?
91
93
  return "# #{name}\n\nNo callbacks defined.\n\n_Next: `rails_get_model_details(model:\"#{name}\")` for full model detail._"
92
94
  end
93
95
 
94
96
  lines = [ "# #{name} - Callbacks", "" ]
97
+ if callbacks.empty?
98
+ lines << "No record callbacks defined."
99
+ lines.concat(association_section(association_lines))
100
+ lines << "" << "_Next: `rails_get_model_details(model:\"#{name}\")` for associations and validations_"
101
+ return lines.join("\n")
102
+ end
95
103
 
96
104
  # Organize callbacks by type, in Rails event order
97
105
  ordered = order_callbacks(callbacks)
@@ -126,6 +134,9 @@ module RailsAiContext
126
134
  end
127
135
  end
128
136
 
137
+ note = commit_order_note([ name ]) if data[:commit_order_unread]
138
+ lines << "" << note if note
139
+
129
140
  # Which concern declared what. The callbacks themselves are already in
130
141
  # the execution-order list above, with their bodies at detail:full, so
131
142
  # repeating either here prints the same declaration twice.
@@ -139,6 +150,8 @@ module RailsAiContext
139
150
  end
140
151
  end
141
152
 
153
+ lines.concat(association_section(association_lines))
154
+
142
155
  # Cross-reference hints
143
156
  lines << ""
144
157
  lines << "_Next: `rails_get_model_details(model:\"#{name}\")` for associations and validations"
@@ -147,40 +160,74 @@ module RailsAiContext
147
160
  lines.join("\n")
148
161
  end
149
162
 
150
- private_class_method def self.list_all_callbacks(models, detail, ctx)
151
- # Filter to models that have callbacks
152
- models_with_callbacks = models.select do |_name, data|
153
- data.is_a?(Hash) && !data[:error] && data[:callbacks].is_a?(Hash) && data[:callbacks].any?
163
+ ASSOCIATION_CALLBACKS = %w[before_add after_add before_remove after_remove].freeze
164
+
165
+ private_class_method def self.association_section(association_lines)
166
+ return [] if association_lines.empty?
167
+
168
+ [ "", "## Association callbacks", "_Run when a record is added to or removed from the collection:_", *association_lines ]
169
+ end
170
+
171
+ private_class_method def self.association_callback_lines(data)
172
+ hooked_associations(data).map do |a, hooks|
173
+ "- **#{a[:name]}** #{hooks.map { |hook, target| "#{hook} → #{target}" }.join(', ')}"
154
174
  end
175
+ end
176
+
177
+ private_class_method def self.association_list(data)
178
+ hooked = association_callback_lines(data)
179
+ hooked.any? ? [ "_Association callbacks:_", *hooked ] : []
180
+ end
181
+
182
+ private_class_method def self.hooked_associations(data)
183
+ Array(data[:associations]).filter_map do |a|
184
+ hooks = (a[:declared_options] || {}).slice(*ASSOCIATION_CALLBACKS)
185
+ [ a, hooks ] if hooks.any?
186
+ end
187
+ end
188
+
189
+ private_class_method def self.list_all_callbacks(models, detail, ctx)
190
+ models_with_callbacks = models.filter_map do |name, data|
191
+ next unless data.is_a?(Hash) && !data[:error]
192
+
193
+ data = data.merge(callbacks: data[:callbacks].is_a?(Hash) ? data[:callbacks] : {})
194
+ [ name, data ] if data[:callbacks].any? || hooked_associations(data).any?
195
+ end.to_h
155
196
 
156
197
  if models_with_callbacks.empty?
157
198
  return text_response("No models with callbacks found.")
158
199
  end
159
200
 
160
201
  lines = [ "# Model Callbacks (#{count_phrase(models_with_callbacks.size, "model")})", "" ]
202
+ # By name within a count, so a model gaining a callback moves only its own section.
203
+ by_count = models_with_callbacks.sort_by { |name, data| [ -data[:callbacks].values.flatten.size, name.to_s ] }
161
204
 
162
205
  case detail
163
206
  when "summary"
164
- models_with_callbacks.sort_by { |_name, data| -(data[:callbacks]&.values&.flatten&.size || 0) }.each do |name, data|
207
+ by_count.each do |name, data|
165
208
  total = data[:callbacks].values.flatten.size
166
209
  types = data[:callbacks].keys.map { |t| callback_type_label(t) }.join(", ")
167
- lines << "- **#{name}** - #{count_phrase(total, "callback")} (#{types})"
210
+ parts = []
211
+ parts << "#{count_phrase(total, "callback")} (#{types})" if total.positive?
212
+ hooked = hooked_associations(data).map { |a, _| a[:name] }
213
+ parts << "association callbacks on #{hooked.join(", ")}" if hooked.any?
214
+ lines << "- **#{name}** - #{parts.join("; ")}"
168
215
  end
169
216
  lines << "" << "_Use `model:\"Name\"` for callbacks by type._"
170
217
 
171
218
  when "standard"
172
- models_with_callbacks.sort_by { |_name, data| -(data[:callbacks]&.values&.flatten&.size || 0) }.each do |name, data|
219
+ by_count.each do |name, data|
173
220
  ordered = order_callbacks(data[:callbacks])
174
221
  lines << "## #{name}"
175
222
  ordered.each do |type, methods|
176
223
  lines << "- **#{callback_type_label(type)}** → #{format_targets(methods, data, type)}"
177
224
  end
178
- lines << ""
225
+ lines.concat(association_list(data)) << ""
179
226
  end
180
227
  lines << "_Use `model:\"Name\"` with `detail:\"full\"` for callback source code._"
181
228
 
182
229
  when "full"
183
- models_with_callbacks.sort_by { |_name, data| -(data[:callbacks]&.values&.flatten&.size || 0) }.each do |name, data|
230
+ by_count.each do |name, data|
184
231
  ordered = order_callbacks(data[:callbacks])
185
232
  lines << "## #{name}"
186
233
  ordered.each do |type, methods|
@@ -194,10 +241,13 @@ module RailsAiContext
194
241
  end
195
242
  end
196
243
  end
197
- lines << ""
244
+ lines.concat(association_list(data)) << ""
198
245
  end
199
246
  end
200
247
 
248
+ unread = models_with_callbacks.select { |_, data| data[:commit_order_unread] }.keys
249
+ note = commit_order_note(unread) unless detail == "summary"
250
+ lines << "" << note if note
201
251
  text_response(lines.join("\n"))
202
252
  end
203
253
 
@@ -253,11 +303,12 @@ module RailsAiContext
253
303
  # that is not the model file.
254
304
  private_class_method def self.source_location(source)
255
305
  prefix = source[:from_concern] ? "#{source[:from_concern]} " : ""
306
+ return "#{prefix}line #{source[:start_line]}" if source[:start_line] == source[:end_line]
307
+
256
308
  "#{prefix}lines #{source[:start_line]}-#{source[:end_line]}"
257
309
  end
258
310
 
259
- # The conditions arrive in step with the names, one entry per
260
- # occurrence, so the same method declared twice keeps its own condition.
311
+ # The conditions arrive in step with the names, one entry per callback.
261
312
  private_class_method def self.format_targets(methods, data = nil, type = nil)
262
313
  per_type = Array(((data && data[:callback_conditions]) || {})[type.to_s])
263
314
  methods.each_with_index.map do |m, i|