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
@@ -65,10 +65,12 @@ module RailsAiContext
65
65
  # "Post" and "Admin::ActionLog" are model names here, and the model
66
66
  # tier knows the table each of them reads.
67
67
  table_as_table = RailsAiContext::Introspectors::TableName.for_model_name(table, models_data)
68
- table_key = tables.keys.find { |k|
68
+ _, table_key, table_data = Payload.schema_tables(schema).find { |_, k, _|
69
69
  k.downcase == table_down || k == table_as_table || k == table.underscore
70
- } || table
71
- table_data = tables[table_key]
70
+ }
71
+ databases = table_data ? Payload.schema_databases(schema, table_key) : []
72
+ database = databases.join(", ") unless databases == [ "primary" ]
73
+ table_key ||= table
72
74
  unless table_data
73
75
  # A table db/schema.rb declares and the connection does not have
74
76
  # is not a misspelling: the migration that adds it has not run
@@ -80,18 +82,35 @@ module RailsAiContext
80
82
  declared = table_key.to_s.underscore
81
83
  if declared_not_connected(schema).include?(declared)
82
84
  return text_response(
83
- "Table '#{declared}' is declared in db/schema.rb and missing from the connected database. " \
85
+ "Table '#{declared}' is declared in #{schema[:declared_in] || "db/schema.rb"} and missing from the connected database. " \
84
86
  "#{pending_for_table(schema, declared)}" \
85
87
  "Run `rails db:migrate`, or pass `--no-boot` to read the declaration instead."
86
88
  )
87
89
  end
88
90
 
89
- return not_found_response("Table", table, tables.keys.sort,
90
- recovery_tool: "Call rails_get_schema(detail:\"summary\") to see all tables")
91
+ recovery = "Call rails_get_schema(detail:\"summary\") to see all tables"
92
+ # Rails' own schema.rb dumper writes no view; only a gem such as scenic adds them.
93
+ if [ schema[:adapter], schema[:adapter_source] ].include?("static_parse")
94
+ recovery += ". Without a connection a view is listed only when the dump records it: structure.sql does, schema.rb only through a gem such as scenic"
95
+ end
96
+ return not_found_response("Table", table, Payload.schema_tables(schema).map { |_, name, _| name }.uniq.sort, recovery_tool: recovery)
97
+ end
98
+ # Databases holding the same table read as one; ones that differ each show their own.
99
+ groups = Payload.schema_tables(schema).select { |_, name, _| name == table_key }
100
+ .group_by { |_, _, data| data }.map { |data, rows| [ rows.map { |db, _, _| db || "primary" }, data ] }
101
+ if groups.size > 1
102
+ return json_response(table: table_key, databases: groups.flat_map { |dbs, data| dbs.map { |db| [ db, data.except(:unread_calls) ] } }.to_h) if format == "json"
103
+
104
+ output = groups.map do |dbs, data|
105
+ in_db = models_data.select { |_, model| model.is_a?(Hash) && model[:table_name] == table_key && Payload.model_databases(schema, model).intersect?(dbs) }
106
+ format_table_markdown(table_key, data, in_db, schema[:enum_types]).sub("\n\n", "\n\n**Database:** #{dbs.join(", ")}\n")
107
+ end.join("\n\n")
108
+ else
109
+ return json_response(table_data.except(:unread_calls).merge({ database: database }.compact)) if format == "json"
110
+
111
+ output = format_table_markdown(table_key, table_data, models_data, schema[:enum_types])
112
+ output = output.sub("\n\n", "\n\n**Database:** #{database}\n") if database
91
113
  end
92
- return json_response(table_data.except(:unread_calls)) if format == "json"
93
-
94
- output = format_table_markdown(table_key, table_data, models_data)
95
114
  # Cross-reference hint for AI: suggest next tool call
96
115
  model_refs = models_for_table(table_key, models_data)
97
116
  if model_refs.any?
@@ -110,14 +129,14 @@ module RailsAiContext
110
129
  return text_response("No tables at offset #{page[:offset]}. Total: #{total}. Use `offset:0` to start over.")
111
130
  end
112
131
 
113
- lines = [ "# Schema Summary (#{count_phrase(total, "table")})", "" ]
132
+ lines = [ "# Schema Summary (#{Introspectors::SchemaConventions.relations_phrase(tables)})", "" ]
114
133
  lines << "**Adapter:** #{adapter_label(ctx)}" if schema[:adapter]
115
134
  lines.concat(static_source_lines(schema))
116
135
  paginated.each do |name|
117
136
  data = tables[name]
118
137
  col_count = data[:columns]&.size || 0
119
138
  idx_count = data[:indexes]&.size || 0
120
- lines << "- **#{name}** - #{count_phrase(col_count, "column")}, #{count_phrase(idx_count, "index", plural: "indexes")}"
139
+ lines << "- **#{name}**#{relation_suffix(data)} - #{count_phrase(col_count, "column")}, #{count_phrase(idx_count, "index", plural: "indexes")}"
121
140
  end
122
141
  coverage = model_coverage_lines(tables, models_data)
123
142
  lines.concat([ "" ] + coverage) if coverage.any?
@@ -135,11 +154,11 @@ module RailsAiContext
135
154
  paginated = page[:items]
136
155
  return json_page_response(schema, tables, paginated, models_data) if format == "json"
137
156
 
138
- if paginated.empty?
157
+ if paginated.empty? && total > 0
139
158
  return text_response("No tables at offset #{page[:offset]}. Total tables: #{total}. Use `offset:0` to start from the beginning.")
140
159
  end
141
160
 
142
- lines = [ "# Schema (#{count_phrase(total, "table")}, showing #{paginated.size})", "" ]
161
+ lines = [ "# Schema (#{Introspectors::SchemaConventions.relations_phrase(tables)}, showing #{paginated.size})", "" ]
143
162
  lines.concat(static_source_lines(schema))
144
163
  paginated.each do |name|
145
164
  data = tables[name]
@@ -201,7 +220,7 @@ module RailsAiContext
201
220
  end
202
221
  # A clause can hold a column list, so clauses part with a semicolon.
203
222
  hint_str = hints.any? ? " [#{hints.join('; ')}]" : ""
204
- "#{c[:name]}:#{c[:type]}#{hint_str}"
223
+ "#{[ c[:name], c[:type] ].compact.join(':')}#{hint_str}"
205
224
  end.join(", ")
206
225
  # Inline model info so AI doesn't need a separate get_model_details call
207
226
  # Every model on the table, richest first: an STI child or a
@@ -221,15 +240,16 @@ module RailsAiContext
221
240
  else
222
241
  ""
223
242
  end
224
- lines << "### #{name}#{model_info}"
225
- lines << cols
243
+ key = data[:primary_key] && data[:primary_key] != "id" ? " (primary key: #{RailsAiContext::Introspectors::SchemaConventions.primary_key_label(data[:primary_key])})" : ""
244
+ lines << "### #{name}#{key}#{relation_suffix(data)}#{model_info}"
245
+ lines << cols unless cols.empty? && data[:columns].blank?
226
246
  lines << ""
227
247
  end
228
248
 
229
249
  coverage = model_coverage_lines(tables, models_data)
230
250
  lines.concat(coverage + [ "" ]) if coverage.any?
231
251
  lines.concat(secondary_databases_lines(schema))
232
- lines << "_Use `detail:\"summary\"` for all #{count_phrase(total, "table")}, `detail:\"full\"` for indexes/FKs, or `table:\"name\"` for one table._" if total > page[:limit]
252
+ lines << "_Use `detail:\"summary\"` for all #{Introspectors::SchemaConventions.relations_phrase(tables)}, `detail:\"full\"` for indexes/FKs, or `table:\"name\"` for one table._" if total > page[:limit]
233
253
  text_response(lines.join("\n"))
234
254
 
235
255
  when "full"
@@ -241,10 +261,10 @@ module RailsAiContext
241
261
  return text_response("No tables at offset #{page[:offset]}. Total: #{total}. Use `offset:0` to start over.")
242
262
  end
243
263
 
244
- lines = [ "# Schema Full Detail (#{paginated.size} of #{count_phrase(total, "table")})", "" ]
264
+ lines = [ "# Schema Full Detail (#{paginated.size} of #{Introspectors::SchemaConventions.relations_phrase(tables)})", "" ]
245
265
  lines.concat(note_lines(schema))
246
266
  paginated.each do |name|
247
- lines << format_table_markdown(name, tables[name], models_data)
267
+ lines << format_table_markdown(name, tables[name], models_data, schema[:enum_types])
248
268
  lines << ""
249
269
  end
250
270
  coverage = model_coverage_lines(tables, models_data)
@@ -355,12 +375,14 @@ module RailsAiContext
355
375
  # schema version recorded by the dump, and migration files it doesn't
356
376
  # cover - the static-tier stand-ins for a live connection's answers.
357
377
  private_class_method def self.note_lines(schema)
358
- schema[:note].to_s.empty? ? [] : [ "_#{schema[:note]}_" ]
378
+ lines = schema[:note].to_s.empty? ? [] : [ "_#{schema[:note]}_" ]
379
+ lines << "**Extensions:** #{schema[:extensions].join(', ')}" if schema[:extensions]&.any?
380
+ lines
359
381
  end
360
382
 
361
383
  private_class_method def self.static_source_lines(schema)
362
384
  lines = note_lines(schema)
363
- lines << "**Dialect:** #{schema[:dialect]} (db/structure.sql)" if schema[:dialect] && schema[:dialect] != "unknown"
385
+ lines << "**Dialect:** #{schema[:dialect]}" if schema[:dialect] && schema[:dialect] != "unknown"
364
386
  lines << "**Schema version:** #{schema[:schema_version]}" if schema[:schema_version]
365
387
  # The header pairs a live table count with the version stamp read off
366
388
  # db/schema.rb, and nothing joined the two: at that migration the app
@@ -368,16 +390,15 @@ module RailsAiContext
368
390
  missing = declared_not_connected(schema)
369
391
  if missing.any?
370
392
  declared_total = Array(schema[:declared_tables]).size
371
- lines << "_db/schema.rb declares #{count_phrase(declared_total, "table")}; the connected database has " \
372
- "#{(schema[:tables] || {}).size}. Missing: #{missing.sort.first(5).join(', ')}" \
393
+ connected_total = Introspectors::SchemaConventions.table_count(schema[:tables] || {})
394
+ lines << "_#{schema[:declared_in] || "db/schema.rb"} declares #{count_phrase(declared_total, "table")}; the connected database has " \
395
+ "#{connected_total}. Missing: #{missing.sort.first(5).join(', ')}" \
373
396
  "#{missing.size > 5 ? " (+#{missing.size - 5} more)" : ""}. Run `rails db:migrate`._"
374
397
  end
375
398
  if schema[:pending_migrations].is_a?(Array)
376
399
  pending = schema[:pending_migrations]
377
400
  if pending.any?
378
- shown = pending.first(5).map { |m| m[:version] }.join(", ")
379
- more = pending.size > 5 ? " (+#{pending.size - 5} more)" : ""
380
- lines << "**Pending migrations:** #{pending.size} - #{shown}#{more}"
401
+ lines << "**Pending migrations:** #{pending_phrase(pending)}"
381
402
  elsif schema[:schema_version]
382
403
  lines << "**Pending migrations:** none"
383
404
  end
@@ -395,12 +416,18 @@ module RailsAiContext
395
416
 
396
417
  lines = [ "", "## Secondary databases", "" ]
397
418
  secondary.each do |name, db|
398
- count = db[:total_tables]
399
- lines << "- **#{name}**: #{count_phrase(count, "table")} (#{db[:tables].keys.join(', ')}) - #{db[:note]}"
419
+ pending = Array(db[:pending_migrations])
420
+ pending_text = pending.any? ? "; pending migrations: #{pending_phrase(pending)}" : ""
421
+ lines << "- **#{name}**: #{Introspectors::SchemaConventions.relations_phrase(db[:tables])} (#{db[:tables].keys.join(', ')}) - #{db[:note]}#{pending_text}"
400
422
  end
401
423
  lines
402
424
  end
403
425
 
426
+ private_class_method def self.pending_phrase(pending)
427
+ more = pending.size > 5 ? " (+#{pending.size - 5} more)" : ""
428
+ "#{pending.size} - #{pending.first(5).map { |m| m[:version] }.join(', ')}#{more}"
429
+ end
430
+
404
431
  # One JSON shape for every detail level: the schema as introspected,
405
432
  # with :tables cut down to the page the same pagination produced for
406
433
  # markdown. `detail` still decides how many tables a page holds.
@@ -416,7 +443,8 @@ module RailsAiContext
416
443
  # Over every table, not the page: which ones no model file claims, and
417
444
  # of those, which a gem the app bundles owns.
418
445
  private_class_method def self.model_coverage(tables, models)
419
- unclaimed = tables.keys.sort.select { |name| models_for_table(name, models).empty? } - habtm_join_tables(models).to_a
446
+ # A view or virtual table often has no model and is still read, so only tables count.
447
+ unclaimed = tables.keys.sort.select { |name| !tables[name][:kind] && models_for_table(name, models).empty? } - habtm_join_tables(models).to_a
420
448
  unclaimed -= declared_join_tables(models).to_a if unclaimed.any?
421
449
  # A file the walk could not read claims no table, but it is still a model file.
422
450
  unclaimed -= models.filter_map { |_, d| d[:table_name] || Introspectors::TableName.stem(d[:file]) if d.is_a?(Hash) && d[:error] && d[:file] }
@@ -446,78 +474,149 @@ module RailsAiContext
446
474
  "#{names.first(COVERAGE_CAP).join(', ')}, and #{names.size - COVERAGE_CAP} more"
447
475
  end
448
476
 
449
- private_class_method def self.format_table_markdown(name, data, models)
477
+ # The type as a migration declares it: `decimal(10,2)`, `string, limit: 20`.
478
+ private_class_method def self.column_type_label(col)
479
+ label = col[:type].to_s
480
+ sizes = [ col[:precision], col[:scale] ].compact
481
+ label += "(#{sizes.join(',')})" if col[:precision]
482
+ label += "[]" if col[:array]
483
+ label += ", limit: #{col[:limit]}" if col[:limit]
484
+ label += ", size: #{col[:size]}" if col[:size]
485
+ label += ", unsigned" if col[:unsigned]
486
+ label += ", collation: #{col[:collation]}" if col[:collation]
487
+ label += ", enum_type: #{col[:enum_type]}" if col[:enum_type]
488
+ label
489
+ end
490
+
491
+ # Options are joined by semicolons because a value can list columns.
492
+ private_class_method def self.index_options_text(idx)
493
+ parts = []
494
+ parts << "using: #{idx[:using]}" if idx[:using]
495
+ parts << "type: #{idx[:type]}" if idx[:type]
496
+ parts << "include: #{idx[:include].join(', ')}" if idx[:include]
497
+ %i[order opclass length].each do |key|
498
+ parts << "#{key}: #{idx[key].map { |column, value| "#{column} #{value}" }.join(', ')}" if idx[key]
499
+ end
500
+ parts << "nulls not distinct" if idx[:nulls_not_distinct]
501
+ parts.any? ? " - #{parts.join('; ')}" : ""
502
+ end
503
+
504
+ RELATION_KINDS = { "view" => "View", "materialized_view" => "Materialized view", "virtual_table" => "Virtual table" }.freeze
505
+
506
+ # What a listed name is when it is not a plain table.
507
+ private_class_method def self.relation_suffix(data)
508
+ label = case data[:kind]
509
+ when "virtual_table" then "#{data[:module]} virtual table".strip
510
+ when "view", "materialized_view" then RELATION_KINDS[data[:kind]].downcase
511
+ else "not dumped" if data[:not_dumped]
512
+ end
513
+ label ? " (#{label})" : ""
514
+ end
515
+
516
+ private_class_method def self.index_lines(data)
517
+ return [] unless data[:indexes]&.any?
518
+
519
+ [ "", "### Indexes" ] + data[:indexes].map do |idx|
520
+ unique = idx[:unique] ? " (unique)" : ""
521
+ "- `#{idx[:name]}` on (#{Array(idx[:columns]).join(', ')})#{unique}#{RailsAiContext::Introspectors::SchemaConventions.where_clause(idx[:where])}#{index_options_text(idx)}"
522
+ end
523
+ end
524
+
525
+ private_class_method def self.format_table_markdown(name, data, models, enum_types = nil)
450
526
  columns = data[:columns] || []
451
527
  # Always show Nullable and Default - agents need these for migrations and validations
452
528
  has_defaults = columns.any? { |c| c.key?(:default) && !c[:default].nil? }
453
529
 
454
530
  model_refs = models_for_table(name, models)
455
- lines = [ "## Table: #{name}", "" ]
531
+ lines = [ "## #{RELATION_KINDS.fetch(data[:kind].to_s, "Table")}: #{name}", "" ]
456
532
  lines << "**Models:** #{model_refs.join(', ')}" if model_refs.any?
533
+ lines << "**Module:** #{data[:module]}" if data[:module]
534
+ lines << "**Primary key:** #{RailsAiContext::Introspectors::SchemaConventions.primary_key_label(data[:primary_key])}" if data[:primary_key]
535
+ lines << "**Comment:** #{data[:comment]}" if data[:comment]
536
+ if data[:not_dumped]
537
+ lines << "The schema dumper could not describe this table (#{data[:not_dumped]}), so the dump holds no columns for it."
538
+ elsif data[:sql] && columns.empty?
539
+ lines << "A view's columns are read from the database, and this answer has no connection: boot the app to list them."
540
+ end
541
+ # A table right after a paragraph line would read as part of it.
542
+ lines << "" if lines.size > 2
543
+ definition = data[:sql] ? [ "### Definition", "```sql", data[:sql], "```" ] : []
544
+ if columns.empty? && (data[:sql] || data[:not_dumped])
545
+ indexes = index_lines(data)
546
+ return lines.concat(indexes.any? ? indexes.drop(1) + [ "" ] : []).concat(definition).join("\n").rstrip
547
+ end
457
548
 
458
- header = "| Column | Type | Null"
459
- sep = "|--------|------|-----"
549
+ has_comments = columns.any? { |c| c[:comment] && !c[:comment].to_s.empty? }
550
+ # A virtual table's module, not a column type, decides what its columns hold.
551
+ typed = columns.any? { |c| c[:type] }
552
+ header = typed ? "| Column | Type | Null" : "| Column"
553
+ sep = typed ? "|--------|------|-----" : "|--------"
460
554
  header += " | Default" if has_defaults
461
555
  sep += "-|---------" if has_defaults
556
+ header += " | Comment" if has_comments
557
+ sep += "-|---------" if has_comments
462
558
  lines << "#{header} |" << "#{sep}|"
463
559
 
464
- has_comments = columns.any? { |c| c[:comment] && !c[:comment].to_s.empty? }
465
-
466
560
  columns.each do |col|
467
561
  nullable = col.key?(:null) ? (col[:null] ? "yes" : "**NO**") : "yes"
468
- col_type = col[:array] ? "#{col[:type]}[]" : col[:type].to_s
469
- line = "| #{col[:name]} | #{col_type} | #{nullable}"
562
+ line = typed ? "| #{col[:name]} | #{column_type_label(col)} | #{nullable}" : "| #{col[:name]}"
470
563
  if has_defaults
471
564
  default_val = col[:default]
472
565
  display_default = default_val == "" ? '""' : default_val
473
566
  line += " | #{display_default}"
474
567
  end
568
+ line += " | #{col[:comment].to_s.gsub('|', '\\|').gsub(/\s*\n\s*/, ' ')}" if has_comments
475
569
  lines << "#{line} |"
476
- lines << " _#{col[:comment]}_" if has_comments && col[:comment] && !col[:comment].to_s.empty?
477
570
  end
571
+ if data[:inherits_unresolved]&.any?
572
+ parents = data[:inherits_unresolved].map { |parent| "`#{parent}`" }.join(", ")
573
+ lines << "" << "Inherits from #{parents}, which the structure.sql dump does not define: its columns are not shown."
574
+ end
575
+
576
+ lines.concat(index_lines(data))
478
577
 
479
- if data[:indexes]&.any?
480
- lines << "" << "### Indexes"
481
- data[:indexes].each do |idx|
482
- unique = idx[:unique] ? " (unique)" : ""
483
- lines << "- `#{idx[:name]}` on (#{Array(idx[:columns]).join(', ')})#{unique}#{RailsAiContext::Introspectors::SchemaConventions.where_clause(idx[:where])}"
578
+ if data[:unique_constraints]&.any?
579
+ lines << "" << "### Unique constraints"
580
+ data[:unique_constraints].each do |constraint|
581
+ deferrable = constraint[:deferrable] ? ", deferrable: #{constraint[:deferrable]}" : ""
582
+ lines << "- `#{constraint[:name]}` on (#{Array(constraint[:columns]).join(', ')})#{deferrable}"
484
583
  end
485
584
  end
486
585
 
487
586
  if data[:foreign_keys]&.any?
488
587
  lines << "" << "### Foreign keys"
489
588
  data[:foreign_keys].each do |fk|
589
+ actions = fk.slice(:on_delete, :on_update, :deferrable, :validate).map { |key, value| "#{key}: #{value}" }
490
590
  lines << "- `#{RailsAiContext::Introspectors::SchemaConventions.key_text(fk[:column])}` → " \
491
- "`#{fk[:to_table]}.#{RailsAiContext::Introspectors::SchemaConventions.key_text(fk[:primary_key])}`"
591
+ "`#{fk[:to_table]}.#{RailsAiContext::Introspectors::SchemaConventions.key_text(fk[:primary_key])}`" \
592
+ "#{" (#{actions.join(', ')})" if actions.any?}"
492
593
  end
493
594
  end
494
595
 
495
- # Check constraints (full detail)
496
596
  if data[:check_constraints]&.any?
497
- lines << "" << "### Check Constraints"
498
- data[:check_constraints].each do |cc|
499
- label = cc[:name] ? "`#{cc[:name]}`" : ""
500
- lines << "- #{label} #{cc[:expression]}"
597
+ lines << "" << "### Check constraints"
598
+ data[:check_constraints].each do |constraint|
599
+ lines << "- #{"`#{constraint[:name]}`: " if constraint[:name]}#{constraint[:expression]}"
501
600
  end
502
601
  end
503
602
 
504
- # Enum types (full detail)
505
- if data[:enum_types]&.any?
506
- lines << "" << "### Enum Types"
507
- data[:enum_types].each do |et|
508
- values = et[:values]&.join(", ") || ""
509
- lines << "- `#{et[:name]}`: #{values}"
510
- end
603
+ used = columns.filter_map { |c| c[:enum_type] }
604
+ enums = Array(enum_types).select { |enum| used.include?(enum[:name]) }
605
+ if enums.any?
606
+ lines << "" << "### Enum types"
607
+ enums.each { |enum| lines << "- `#{enum[:name]}`: #{Array(enum[:values]).join(', ')}" }
511
608
  end
512
609
 
513
- # Generated columns (full detail)
514
- if data[:generated_columns]&.any?
515
- lines << "" << "### Generated Columns"
516
- data[:generated_columns].each do |gc|
517
- lines << "- `#{gc[:name]}` - #{gc[:expression]}"
610
+ generated = columns.select { |c| c.key?(:generated) }
611
+ if generated.any?
612
+ lines << "" << "### Generated columns"
613
+ generated.each do |col|
614
+ expression = col[:generated].to_s.empty? ? "" : ": #{col[:generated]}"
615
+ lines << "- `#{col[:name]}`#{expression} (#{col[:stored] ? "stored" : "virtual"})"
518
616
  end
519
617
  end
520
618
 
619
+ lines.concat([ "" ] + definition) if definition.any?
521
620
  lines.join("\n")
522
621
  end
523
622
  end
@@ -4,7 +4,7 @@ module RailsAiContext
4
4
  module Tools
5
5
  class GetServicePattern < BaseTool
6
6
  tool_name "rails_get_service_pattern"
7
- description "Analyze service objects in app/services/: patterns, interfaces, dependencies, and side effects. " \
7
+ description "Analyze service objects in app/services/, app/interactions/ and app/interactors/: patterns, interfaces, dependencies, and side effects. " \
8
8
  "Use when: understanding how services are structured, adding a new service, or tracing what a service does. " \
9
9
  "Specify service:\"CreateOrder\" for full detail, or omit to detect the common pattern and list all services."
10
10
 
@@ -26,15 +26,17 @@ module RailsAiContext
26
26
 
27
27
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
28
28
 
29
+ SERVICE_ROOTS = Introspectors::ServiceClasses::ROOTS
30
+
29
31
  def self.call(service: nil, detail: "standard", server_context: nil)
30
32
  blank = blank_name_response("service", service)
31
33
  return blank if blank
32
34
 
33
35
  root = rails_app.root.to_s
34
- service_dirs = PathResolver.dirs_for(root, "app/services")
36
+ service_dirs = SERVICE_ROOTS.flat_map { |kind| PathResolver.dirs_for(root, kind) }.uniq
35
37
 
36
38
  if service_dirs.empty?
37
- searched = PathResolver.search_patterns(root, "app/services").map { |pattern| "#{pattern}/" }
39
+ searched = SERVICE_ROOTS.flat_map { |kind| PathResolver.search_patterns(root, kind) }.uniq.map { |pattern| "#{pattern}/" }
38
40
  return text_response("No services directory found. Searched #{searched.join(', ')}. " \
39
41
  "This app may not use the service objects pattern.")
40
42
  end
@@ -127,11 +129,20 @@ module RailsAiContext
127
129
  lines.concat(inputs)
128
130
  end
129
131
 
130
- owned = owned_methods(source, constant_for(file, service_dirs))
132
+ owned, macros, steps = class_interface(source, constant_for(file, service_dirs))
133
+ built = macro_constructor(macros, record, lookup)
131
134
 
132
- # Initialize params
133
- init_params = extract_initialize_params(owned)
135
+ init_params = extract_initialize_params(owned) || built&.dig(:signature)
134
136
  lines << "**Initialize:** `#{init_params}`" if init_params
137
+ if built
138
+ lines << "" << "## Inputs (#{built[:library]})"
139
+ built[:inputs].each { |input| lines << "- #{input}" }
140
+ end
141
+
142
+ if steps.any?
143
+ lines << "" << "## Organizes"
144
+ steps.each_with_index { |step, index| lines << "#{index + 1}. `#{step}`" }
145
+ end
135
146
 
136
147
  # Public methods
137
148
  public_methods = extract_public_methods(owned)
@@ -193,10 +204,10 @@ module RailsAiContext
193
204
  return { path: file } unless source
194
205
  return if Introspectors::ServiceClasses.concern?(nil, source)
195
206
 
196
- class_name, superclass = name_and_superclass(source, file, service_dirs)
207
+ class_name, superclass, nesting = name_and_superclass(source, file, service_dirs)
197
208
  return if Introspectors::ServiceClasses.mailer?(source, class_name, superclass, lookup)
198
209
 
199
- { path: file, source: source, class_name: class_name, superclass: superclass,
210
+ { path: file, source: source, class_name: class_name, superclass: superclass, nesting: nesting,
200
211
  entryless: Introspectors::ServiceClasses.entryless_module?(source, class_name) }
201
212
  end
202
213
 
@@ -211,19 +222,22 @@ module RailsAiContext
211
222
  end
212
223
 
213
224
  private_class_method def self.format_service_listing(service_files, service_dirs, root, detail, lookup)
214
- service_data = service_records(service_files, service_dirs, root, lookup).filter_map do |record|
215
- next unless record[:source]
216
-
225
+ read = service_records(service_files, service_dirs, root, lookup).select { |record| record[:source] }.map do |record|
226
+ [ record, *class_interface(record[:source], constant_for(record[:path], service_dirs)) ]
227
+ end
228
+ # A listed parent's macros are already read, so its children's constructors reuse them.
229
+ walks = read.to_h { |record, _, macros| [ record[:class_name], { name: record[:class_name], superclass: record[:superclass], macros: macros } ] }
230
+ service_data = read.map do |record, owned, macros|
217
231
  file, source = record.values_at(:path, :source)
218
- owned = owned_methods(source, constant_for(file, service_dirs))
219
232
 
220
233
  {
221
234
  file: file.sub("#{root}/", ""),
222
235
  class_name: record[:class_name],
223
236
  superclass: record[:superclass],
237
+ nesting: record[:nesting],
224
238
  line_count: source.lines.size,
225
239
  public_methods: extract_public_methods(owned),
226
- init_params: extract_initialize_params(owned),
240
+ init_params: extract_initialize_params(owned) || macro_constructor(macros, record, lookup, walks)&.dig(:signature),
227
241
  class_method_call: owned.any? { |m| m[:scope] == :class && m[:name] == "call" },
228
242
  result_object: source.match?(/Result\.new|OpenStruct\.new|Struct\.new|\.success|\.failure/),
229
243
  active_interaction: Introspectors::Interaction.interaction?(source, lookup: lookup),
@@ -308,7 +322,7 @@ module RailsAiContext
308
322
  # A base class is not a service a caller invokes: counted as one it
309
323
  # inflates the total and the pattern denominator beside it.
310
324
  private_class_method def self.base_class_names(service_data)
311
- Introspectors::ServiceClasses.abstract_names(service_data.map { |s| [ s[:class_name], s[:superclass] ] })
325
+ Introspectors::ServiceClasses.abstract_names(service_data.map { |s| s.values_at(:class_name, :superclass, :nesting) })
312
326
  end
313
327
 
314
328
  # The class this file declares and the superclass it names, from the one
@@ -379,18 +393,101 @@ module RailsAiContext
379
393
  # outermost definition in the file.
380
394
  #
381
395
  # One walk per file: the interface and the constructor have to resolve
382
- # the same owner, and a second walk could pick a different one.
383
- private_class_method def self.owned_methods(source, expected_constant = nil)
396
+ # the same owner, and a second walk could pick a different one. The
397
+ # constructor macros come from the same walk, outside any `def`.
398
+ private_class_method def self.class_interface(source, expected_constant = nil)
384
399
  ast = Introspectors::SourceIntrospector.walk_source(
385
- source, { methods: -> { Introspectors::Listeners::MethodsListener.new(include_initialize: true) } }
400
+ source, { methods: -> { Introspectors::Listeners::MethodsListener.new(include_initialize: true) },
401
+ macros: Introspectors::Listeners::ConstructorMacroListener,
402
+ organize: -> { Introspectors::Listeners::GenericMacroListener.new(:organize) } }
386
403
  )
387
404
  methods = ast[:methods] || []
388
- return [] if methods.empty?
389
-
390
- owner = primary_owner(methods, expected_constant)
391
- Introspectors::ActionResolver.own_methods(methods, owner)
405
+ macros = Introspectors::SourceIntrospector.outside_defs(ast[:macros], methods)
406
+ owners = (methods + macros).map { |m| Introspectors::ActionResolver.owner_name(m) }
407
+ # An organizer's steps, in the order interactor runs them.
408
+ steps = Introspectors::SourceIntrospector.outside_defs(ast[:organize], methods).flat_map { |m| Array(m[:values]) }
409
+ .flat_map { |value| Array(value) }.select { |value| value.is_a?(String) }
410
+ return [ [], [], steps ] if owners.empty?
411
+
412
+ owner = primary_owner(owners, expected_constant)
413
+ [ Introspectors::ActionResolver.own_methods(methods, owner),
414
+ macros.select { |m| Introspectors::ActionResolver.owner_name(m) == owner }, steps ]
392
415
  rescue => e
393
- RailsAiContext.debug_fail(e, [], label: "owned_methods AST")
416
+ RailsAiContext.debug_fail(e, [ [], [], [] ], label: "class_interface AST")
417
+ end
418
+
419
+ T_STRUCT_BASES = %w[T::Struct T::ImmutableStruct T::InexactStruct].freeze
420
+ DRY_STRUCT_BASES = %w[Dry::Struct Dry::Struct::Value].freeze
421
+
422
+ # The constructor a library's macros define, kept only when the class is
423
+ # that library's: `attribute` is also ActiveModel's, `param` anybody's.
424
+ # Dry::Struct attributes and dry-initializer params and options add to the
425
+ # parent's, so the app's own superclasses contribute theirs, farthest first.
426
+ INHERITED_MACROS = %i[extend attribute attribute? param option].freeze
427
+
428
+ private_class_method def self.macro_constructor(macros, record, lookup, walks = {})
429
+ inherited = inherited_macros(record, lookup, walks).reverse.flat_map do |parent|
430
+ parent[:macros].select { |m| INHERITED_MACROS.include?(m[:macro]) }.map { |m| m.merge(from: parent[:name]) }
431
+ end
432
+ macros = inherited + macros
433
+ return nil if macros.empty?
434
+
435
+ dry_initializer = macros.any? { |m| m[:macro] == :extend && Array(m[:values]).include?("Dry::Initializer") }
436
+ library = ->(bases) { reaches?(record, bases, lookup) }
437
+ kept = macros.select do |m|
438
+ case m[:macro]
439
+ when :extend then false
440
+ when :const, :prop then library.call(T_STRUCT_BASES)
441
+ when :attribute, :attribute? then library.call(DRY_STRUCT_BASES)
442
+ when :param, :option then dry_initializer
443
+ else true
444
+ end
445
+ end
446
+ return nil if kept.empty?
447
+
448
+ kept = kept.partition { |m| m[:macro] != :option }.flatten if dry_initializer
449
+ names = { const: "T::Struct", prop: "T::Struct", attribute: "Dry::Struct", attribute?: "Dry::Struct",
450
+ param: "dry-initializer", option: "dry-initializer" }
451
+ { library: kept.map { |m| names.fetch(m[:macro], "attr_extras") }.uniq.join(", "),
452
+ signature: "initialize(#{kept.flat_map { |m| m[:params] }.map { |param| param_text(param) }.join(', ')})",
453
+ inputs: kept.map { |m| m[:from] ? "`#{m[:source]}` (from `#{m[:from]}`)" : "`#{m[:source]}`" } }
454
+ end
455
+
456
+ # The app's own superclasses, nearest first, each with the constructor macros its body declares.
457
+ private_class_method def self.inherited_macros(record, lookup, walks)
458
+ chain = []
459
+ seen = [ record[:class_name] ]
460
+ parent = record[:superclass]
461
+ while parent && lookup && chain.size < Introspectors::SuperclassChain::MAX_DEPTH && !seen.include?(parent)
462
+ seen << parent
463
+ entry = walks[parent] ||= begin
464
+ source = lookup.call(parent)
465
+ declared = source && Introspectors::DeclaredConstant.declaration_named(Introspectors::DeclaredConstant.declarations(source, path_name: parent), parent)
466
+ declared ? { name: declared.name, superclass: declared.superclass, macros: class_interface(source, declared.name)[1] } : {}
467
+ end
468
+ break unless entry[:name]
469
+
470
+ chain << entry
471
+ parent = entry[:superclass]
472
+ end
473
+ chain
474
+ end
475
+
476
+ private_class_method def self.reaches?(record, bases, lookup)
477
+ return true if bases.include?(record[:superclass].to_s.delete_prefix("::"))
478
+ return false unless record[:superclass]
479
+
480
+ Introspectors::SuperclassChain.to(record[:source], bases: bases, lookup: lookup, only: record[:class_name]).any?
481
+ end
482
+
483
+ private_class_method def self.param_text(param)
484
+ kind, name, default = param
485
+ case kind
486
+ when :req then name
487
+ when :opt then "#{name} = #{default}"
488
+ when :keyreq then "#{name}:"
489
+ else "#{name}: #{default}"
490
+ end
394
491
  end
395
492
 
396
493
  # A service that defines no constructor answers `.new` with no arguments.
@@ -403,11 +500,10 @@ module RailsAiContext
403
500
  # interface, which is why it is dropped here rather than at the walk.
404
501
  private_class_method def self.extract_public_methods(owned)
405
502
  owned.select { |m| m[:visibility] == :public && m[:name] != "initialize" }
406
- .map { |m| m[:signature] }
503
+ .map { |m| m[:scope] == :class && !m[:signature].start_with?("self.") ? "self.#{m[:signature]}" : m[:signature] }
407
504
  end
408
505
 
409
- private_class_method def self.primary_owner(methods, expected_constant)
410
- owners = methods.map { |m| Introspectors::ActionResolver.owner_name(m) }
506
+ private_class_method def self.primary_owner(owners, expected_constant)
411
507
  return expected_constant if expected_constant && owners.include?(expected_constant)
412
508
 
413
509
  owners.min_by { |o| [ o.count(":"), o.length ] }
@@ -278,7 +278,7 @@ module RailsAiContext
278
278
  raw = RailsAiContext::SafeFile.read(path) or next
279
279
  next unless Introspectors::StimulusIntrospector.identifiers_in(path, raw).include?(token)
280
280
 
281
- dir = view_dirs.find { |d| path.start_with?("#{d}/") }
281
+ dir = RailsAiContext::ViewFile.root_for(path, view_dirs)
282
282
  dir ? [ path.delete_prefix("#{dir}/"), true ] : [ path.delete_prefix("#{root}/"), false ]
283
283
  rescue SystemCallError
284
284
  next