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,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "set"
4
+ require "pathname"
4
5
  require_relative "safe_file"
6
+ require_relative "safe_path"
7
+ require_relative "polyfill/data"
5
8
 
6
9
  module RailsAiContext
7
10
  # Which gems an app resolved, read once per lockfile, and the one answer
@@ -15,28 +18,50 @@ module RailsAiContext
15
18
  MAX_SIZE = 5 * 1024 * 1024
16
19
  SPEC_LINE = /\A {4}(\S+) \(([^)]+)\)\s*\z/
17
20
  DEPENDENCY_LINE = /\A {2}(\S+?)!?(?: \(.*\))?\s*\z/
18
- RUBY_LINE = /\A\s+ruby (\S+)/
21
+ # Bundler writes a non-CRuby engine after the version: "ruby 3.1.4p0 (jruby 9.4.8.0)".
22
+ RUBY_LINE = /\A\s+ruby (\S+)(?: \((\S+) ([^)]+)\))?/
19
23
  REMOTE_LINE = /\A {2}remote: (.+?)\s*\z/
20
- GEMFILE_RUBY_LINE = /^\s*ruby\s+(["'])([^"']+)\1/
21
24
  PLAIN_VERSION = /\A\d+(?:\.\d+)*\S*\z/
22
25
  TOOL_VERSIONS_RUBY = /^ruby[ \t]+(\S+)/
23
- # Bundler's order: what the lockfile resolved, what the Gemfile asked for, then the
24
- # version-manager files the shell picks when neither says.
25
- RUBY_SOURCES = [ "Gemfile.lock", "Gemfile", ".ruby-version", ".tool-versions" ].freeze
26
+ # Version managers write another engine as "<engine>-<version>", as ruby-build names it.
27
+ ENGINE_PREFIXED = /\A([a-z][a-z+]*)-(\d\S*)\z/
28
+ ENGINE_NAMES = {
29
+ "jruby" => "JRuby", "truffleruby" => "TruffleRuby", "truffleruby+graalvm" => "TruffleRuby+GraalVM",
30
+ "mruby" => "mruby", "rbx" => "Rubinius"
31
+ }.freeze
32
+ # mise's project files, highest precedence first (mise docs, configuration).
33
+ MISE_FILES = [ "mise.local.toml", "mise.toml", ".mise.toml", "mise/config.toml", ".mise/config.toml", ".config/mise.toml",
34
+ ".config/mise/config.toml" ].freeze
35
+ VERSION_FILES = [ ".ruby-version", ".tool-versions", *MISE_FILES ].freeze
36
+ MISE_TOOLS = /^[ \t]*\[tools\][ \t]*$(.*?)(?=^[ \t]*\[|\z)/m
37
+ # ruby = "3.3.6", ruby = ["3.3.6", ...] or ruby = { version = "3.3.6" }
38
+ MISE_RUBY = /^[ \t]*["']?ruby["']?[ \t]*=[ \t]*(?:\[[ \t]*|\{[^}\n]*?version[ \t]*=[ \t]*)?["']([^"'\n]+)["']/
26
39
 
27
40
  class Spec
28
- # `remote:` of each PATH section, as the lockfile writes it.
29
- attr_reader :ruby_versions, :reason, :path_remotes
41
+ # `ruby_engine` is a non-CRuby engine with its own version ("JRuby 9.4.8.0"); `outside_gemfile` is never read.
42
+ attr_reader :ruby_versions, :ruby_engine, :reason, :path_remotes, :outside_gemfile
43
+ # With no lockfile, the gems the Gemfile names; nil when it names only some of them.
44
+ attr_reader :gemfile_gems
30
45
 
31
- def initialize(versions, ruby_versions: {}, reason: nil, absent: false, direct: nil, path_remotes: [])
46
+ def initialize(versions, ruby_versions: {}, ruby_engine: nil, reason: nil, absent: false, direct: nil, path_remotes: [],
47
+ outside_gemfile: nil, no_repo: false, gemfile_gems: nil)
32
48
  @versions = versions
49
+ @gemfile_gems = gemfile_gems
50
+ @outside_gemfile = outside_gemfile
51
+ @no_repo = no_repo
33
52
  @path_remotes = path_remotes
34
53
  @ruby_versions = ruby_versions
54
+ @ruby_engine = ruby_engine
35
55
  @reason = reason
36
56
  @absent = absent
37
57
  @direct = direct || Set.new
38
58
  end
39
59
 
60
+ # Why the bundle config/boot.rb names went unread, worded the same by every tool.
61
+ def unread_bundle
62
+ GemLock.unread_bundle(@outside_gemfile, @no_repo) if @outside_gemfile
63
+ end
64
+
40
65
  # Several files may name a version and they often disagree, so the source is kept too.
41
66
  def ruby_version
42
67
  @ruby_versions.values.first
@@ -85,46 +110,161 @@ module RailsAiContext
85
110
  end
86
111
  end
87
112
 
113
+ # `dir` holds the Gemfile and lockfile; `trusted` is the tree a path gem of theirs must stay
114
+ # inside; `outside` names a bundle config/boot.rb points at that is never read, `no_repo` says no git repository holds the app.
115
+ Bundle = Data.define(:lockfile, :gemfile, :lock_label, :gemfile_label, :dir, :trusted, :outside, :no_repo)
116
+
88
117
  MUTEX = Mutex.new
89
118
  CACHE = {}
90
119
  private_constant :MUTEX, :CACHE
91
120
 
92
121
  module_function
93
122
 
123
+ # Bundler looks for gems.rb before Gemfile.
124
+ def gemfile_name(root)
125
+ File.file?(File.join(root.to_s, "gems.rb")) ? "gems.rb" : "Gemfile"
126
+ end
127
+
128
+ # gems.rb locks to gems.locked, Gemfile to Gemfile.lock.
129
+ def lockfile_name(root)
130
+ gemfile_name(root) == "gems.rb" ? "gems.locked" : "Gemfile.lock"
131
+ end
132
+
94
133
  def for(root)
95
134
  root = root.to_s
96
- path = File.join(root, "Gemfile.lock")
97
- gemfile = File.join(root, "Gemfile")
98
- stamp = RUBY_SOURCES.map { |name| mtime(File.join(root, name)) }
135
+ bundle = bundle(root)
136
+ stamp = [ bundle.lockfile, bundle.gemfile, File.join(root, "config/boot.rb"),
137
+ *VERSION_FILES.map { |name| File.join(root, name) } ].map { |file| file && mtime(file) } << loaded?
138
+ stamp << Introspectors::GemfileGems.stamps(bundle) if loaded?
99
139
 
100
140
  MUTEX.synchronize do
101
- cached = CACHE[path]
141
+ cached = CACHE[root]
102
142
  return cached[:spec] if cached && cached[:stamp] == stamp
103
143
 
104
144
  # Which gems resolved and which Ruby the app declares are two facts,
105
145
  # and the Gemfile answers the second whether or not a lockfile answers
106
146
  # the first.
107
147
  spec = if stamp.first
108
- parse(path, gemfile, root)
148
+ parse(bundle, root)
109
149
  else
110
- Spec.new({}, ruby_versions: declared_ruby_versions(nil, root),
111
- reason: "No Gemfile.lock found", absent: true)
150
+ absent_spec(root, bundle)
112
151
  end
113
- CACHE[path] = { stamp: stamp, spec: spec }
152
+ CACHE[root] = { stamp: stamp, spec: spec }
114
153
  spec
115
154
  end
116
155
  end
117
156
 
157
+ # The app's own Gemfile and lockfile, or, with no lockfile of its own, the
158
+ # bundle config/boot.rb points Bundler at (an engine's test/dummy).
159
+ def bundle(root)
160
+ root = root.to_s
161
+ own = Bundle.new(lockfile: File.join(root, lockfile_name(root)), gemfile: File.join(root, gemfile_name(root)),
162
+ lock_label: lockfile_name(root), gemfile_label: gemfile_name(root), dir: root, trusted: root, outside: nil, no_repo: false)
163
+ return own if File.file?(own.lockfile)
164
+
165
+ boot_bundle(root, own) || own
166
+ end
167
+
168
+ # Read only inside the app's git repository, the trust its own Gemfile.lock has; anything else is named and left unread.
169
+ def boot_bundle(root, own)
170
+ target = boot_gemfile(root)
171
+ return nil unless target
172
+
173
+ real_root = File.realpath(root)
174
+ dir = File.dirname(target)
175
+ label = ->(file) { Pathname.new(File.join(dir, file)).relative_path_from(Pathname.new(real_root)).to_s }
176
+ gemfile = File.basename(target)
177
+ lockfile = gemfile == "gems.rb" ? "gems.locked" : "#{gemfile}.lock"
178
+ repo = SafePath.git_root(real_root)
179
+ unless repo && File.directory?(dir) && SafePath.contained?(File.realpath(dir), repo)
180
+ return own.with(lockfile: nil, outside: label.(gemfile), no_repo: repo.nil?)
181
+ end
182
+
183
+ Bundle.new(lockfile: inside_file(dir, lockfile), gemfile: inside_file(dir, gemfile), lock_label: label.(lockfile),
184
+ gemfile_label: label.(gemfile), dir: File.realpath(dir), trusted: repo, outside: nil, no_repo: false)
185
+ rescue SystemCallError
186
+ nil
187
+ end
188
+ private_class_method :boot_bundle
189
+
190
+ # The BUNDLE_GEMFILE config/boot.rb sets, when it is outside the app root.
191
+ def boot_gemfile(root)
192
+ real_root = File.realpath(root)
193
+ real = File.realpath(File.join(real_root, "config/boot.rb"))
194
+ return nil unless SafePath.contained?(real, real_root)
195
+
196
+ result = ruby_parse(real) or return nil
197
+ relative, anchor = Introspectors::AstWalk.each(result.value).lazy.filter_map { |node| bundle_gemfile_path(node) }.first
198
+ return nil unless relative
199
+
200
+ # Relative to __FILE__ the path starts from boot.rb itself, one level below __dir__.
201
+ target = File.expand_path(relative, anchor == :file ? real : File.dirname(real))
202
+ target unless target.start_with?(SafePath.dir_prefix(real_root))
203
+ rescue SystemCallError
204
+ nil
205
+ end
206
+ private_class_method :boot_gemfile
207
+
208
+ # `ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)`, the line `rails new`
209
+ # and `rails plugin new` write, with `=` or `__FILE__` as older templates do.
210
+ def bundle_gemfile_path(node)
211
+ value = case node
212
+ when Prism::IndexOrWriteNode then node.value if env_index?(node.receiver, node.arguments&.arguments)
213
+ when Prism::CallNode then node.arguments&.arguments&.last if node.name == :[]= && env_index?(node.receiver, node.arguments&.arguments&.first(1))
214
+ end
215
+ return nil unless value.is_a?(Prism::CallNode) && value.name == :expand_path && value.receiver.is_a?(Prism::ConstantReadNode) &&
216
+ value.receiver.name == :File
217
+
218
+ path, anchor = value.arguments&.arguments
219
+ return nil unless path.is_a?(Prism::StringNode) && value.arguments.arguments.size == 2
220
+
221
+ if anchor.is_a?(Prism::SourceFileNode) then [ path.unescaped, :file ]
222
+ elsif anchor.is_a?(Prism::CallNode) && anchor.name == :__dir__ && anchor.receiver.nil? then [ path.unescaped, :dir ]
223
+ end
224
+ end
225
+ private_class_method :bundle_gemfile_path
226
+
227
+ def env_index?(receiver, arguments)
228
+ receiver.is_a?(Prism::ConstantReadNode) && receiver.name == :ENV && arguments&.size == 1 && literal(arguments.first) == "BUNDLE_GEMFILE"
229
+ end
230
+ private_class_method :env_index?
231
+
232
+ # The file's real path when it exists and does not link out of its directory.
233
+ def inside_file(dir, name)
234
+ real = File.realpath(File.join(dir, name))
235
+ real if SafePath.contained?(real, File.realpath(dir))
236
+ rescue SystemCallError
237
+ nil
238
+ end
239
+ private_class_method :inside_file
240
+
241
+ def unread_bundle(outside, no_repo)
242
+ where = no_repo ? "and the app is in no git repository" : "outside the app's git repository"
243
+ "config/boot.rb points Bundler at `#{outside}`, #{where}"
244
+ end
245
+
246
+ def absent_spec(root, bundle)
247
+ reason = if bundle.outside
248
+ "No #{lockfile_name(root)} in the app; #{unread_bundle(bundle.outside, bundle.no_repo)}, so that bundle is not read"
249
+ else
250
+ "No #{bundle.lock_label} found"
251
+ end
252
+ facts = gemfile(bundle)
253
+ Spec.new({}, **declared_ruby(nil, root, bundle, facts), reason: reason, absent: true, outside_gemfile: bundle.outside,
254
+ no_repo: bundle.no_repo, gemfile_gems: facts[:gems])
255
+ end
256
+ private_class_method :absent_spec
257
+
118
258
  def mtime(path)
119
259
  File.mtime(path)
120
260
  rescue SystemCallError
121
261
  nil
122
262
  end
123
- private_class_method :mtime
124
263
 
125
- def parse(path, gemfile, root)
264
+ def parse(bundle, root)
265
+ path = bundle.lockfile
126
266
  content = SafeFile.read(path, max_size: MAX_SIZE)
127
- return Spec.new({}, reason: "Gemfile.lock could not be read") unless content
267
+ return Spec.new({}, reason: "#{File.basename(path)} could not be read") unless content
128
268
 
129
269
  versions = {}
130
270
  direct = Set.new
@@ -152,52 +292,140 @@ module RailsAiContext
152
292
  # prerelease tag ("1.70.0-beta1") is dropped along with the platform.
153
293
  versions[match[1]] ||= match[2].split("-", 2).first
154
294
  elsif (match = line.match(RUBY_LINE)) && match[1].match?(PLAIN_VERSION)
155
- ruby_version = match[1]
295
+ ruby_version = [ match[1], engine_name(match[2], match[3]) ]
156
296
  end
157
297
  end
158
298
  # An empty Gemfile still locks to a file with a specs: section, so no
159
299
  # gems is an answer there. A file without one is not a lockfile at all,
160
300
  # and answering it as an app with no gems denies every gem it holds.
161
- return Spec.new({}, reason: "Gemfile.lock has no specs section") unless specs_section
301
+ return Spec.new({}, reason: "#{File.basename(path)} has no specs section") unless specs_section
162
302
 
163
- Spec.new(versions, ruby_versions: declared_ruby_versions(ruby_version, root), direct: direct, path_remotes: path_remotes)
303
+ facts = gemfile(bundle)
304
+ Spec.new(versions, **declared_ruby(ruby_version, root, bundle, facts), direct: direct, path_remotes: path_remotes)
164
305
  end
165
306
  private_class_method :parse
166
307
 
167
- # A lockfile without a RUBY VERSION section leaves the Gemfile as the only
168
- # statement of the version. A requirement such as `ruby ">= 3.3.0"` names a
169
- # range, not a version, so it is left unanswered rather than reported as one.
170
- def gemfile_ruby_version(path)
308
+ # `gems` is nil when a call adds gems the Gemfile does not name (gemspec, a gem
309
+ # or eval_gemfile whose argument is not a literal, a file left unread).
310
+ def gemfile(bundle)
311
+ entries = gemfile_entries(bundle) or return { ruby: nil, gems: nil }
312
+
313
+ ruby = entries.find { |entry| entry[:type] == :ruby }
314
+ gems = entries.filter_map { |entry| entry[:name] if entry[:type] == :gem }.uniq unless entries.any? { |entry| entry[:type] == :unknown_gems }
315
+ { ruby: ruby && gemfile_ruby(ruby), gems: gems }
316
+ end
317
+ private_class_method :gemfile
318
+
319
+ # Before the gem loads, a plain walk of the one Gemfile; eval_gemfile is left unread.
320
+ def gemfile_entries(bundle)
321
+ return Introspectors::GemfileGems.read_bundle(bundle) if loaded?
322
+
323
+ result = ruby_parse(bundle.gemfile) or return nil
324
+ Introspectors::AstWalk.each(result.value).filter_map do |node|
325
+ next unless node.is_a?(Prism::CallNode) && node.receiver.nil?
326
+
327
+ args = node.arguments&.arguments || []
328
+ case node.name
329
+ when :ruby
330
+ options = args.grep(Prism::KeywordHashNode).flat_map(&:elements).grep(Prism::AssocNode).to_h { |pair| [ literal(pair.key), literal(pair.value) ] }
331
+ { type: :ruby, version: literal(args.first), engine: options["engine"], engine_version: options["engine_version"] }
332
+ when :gem then args.first.is_a?(Prism::StringNode) ? { type: :gem, name: args.first.unescaped } : { type: :unknown_gems }
333
+ when :gemspec, :eval_gemfile then { type: :unknown_gems }
334
+ end
335
+ end
336
+ end
337
+ private_class_method :gemfile_entries
338
+
339
+ def loaded?
340
+ defined?(Introspectors::GemfileGems) ? true : false
341
+ end
342
+ private_class_method :loaded?
343
+
344
+ # AstCache once the gem is loaded; before the boot Prism alone, as AstCache's
345
+ # concurrent-ruby would load ahead of the app's bundle.
346
+ def ruby_parse(path)
347
+ return nil unless path
348
+ return AstCache.parse(File.realpath(path)) if loaded?
349
+
350
+ require "prism"
351
+ require_relative "introspectors/ast_walk"
171
352
  content = SafeFile.read(path, max_size: MAX_SIZE)
172
- return nil unless content
353
+ content && Prism.parse(content)
354
+ rescue SystemCallError, ArgumentError, LoadError
355
+ nil
356
+ end
357
+ private_class_method :ruby_parse
173
358
 
174
- declared = content[GEMFILE_RUBY_LINE, 2]
175
- declared if declared&.match?(PLAIN_VERSION)
359
+ # A requirement such as `ruby ">= 3.3.0"` names a range, not a version, so it is left unanswered.
360
+ def gemfile_ruby(entry)
361
+ version = entry[:version]
362
+ [ version, engine_name(entry[:engine], entry[:engine_version]) ] if version&.match?(PLAIN_VERSION)
176
363
  end
177
- private_class_method :gemfile_ruby_version
364
+ private_class_method :gemfile_ruby
178
365
 
179
- def declared_ruby_versions(locked, root)
180
- {
181
- "Gemfile.lock" => locked,
182
- "Gemfile" => gemfile_ruby_version(File.join(root, "Gemfile")),
183
- ".ruby-version" => ruby_version_file(File.join(root, ".ruby-version")),
184
- ".tool-versions" => tool_versions_ruby(File.join(root, ".tool-versions"))
366
+ def literal(node)
367
+ node.unescaped if node.is_a?(Prism::StringNode) || node.is_a?(Prism::SymbolNode)
368
+ end
369
+ private_class_method :literal
370
+
371
+ # Sources in Bundler's order; an engine's Ruby version comes only from its own source (JRuby 9.4 runs Ruby 3.1).
372
+ def declared_ruby(locked, root, bundle, facts)
373
+ declared = {
374
+ bundle.lock_label => locked,
375
+ bundle.gemfile_label => facts[:ruby],
376
+ ".ruby-version" => version_string(read_inside(root, ".ruby-version")&.strip),
377
+ ".tool-versions" => version_string(read_inside(root, ".tool-versions")&.[](TOOL_VERSIONS_RUBY, 1)),
378
+ **mise_ruby(root)
185
379
  }.compact
380
+ engine = declared.values.first&.last
381
+ declared = declared.first(1).to_h if engine
382
+ { ruby_versions: declared.transform_values(&:first).compact, ruby_engine: engine }
186
383
  end
187
- private_class_method :declared_ruby_versions
384
+ private_class_method :declared_ruby
188
385
 
189
- # `.ruby-version` is one line, written either bare or engine-prefixed.
190
- def ruby_version_file(path)
191
- declared = SafeFile.read(path, max_size: MAX_SIZE)&.strip&.sub(/\Aruby-/, "")
192
- declared if declared&.match?(PLAIN_VERSION)
386
+ # SafePath's containment without its sensitive-pattern check, which needs
387
+ # the configuration: the CLI reads GemLock before loading it.
388
+ def read_inside(root, relative)
389
+ real = File.realpath(File.join(root, relative))
390
+ SafeFile.read(real, max_size: MAX_SIZE) if SafePath.contained?(real, File.realpath(root))
391
+ rescue SystemCallError
392
+ nil
193
393
  end
194
- private_class_method :ruby_version_file
394
+ private_class_method :read_inside
195
395
 
196
- def tool_versions_ruby(path)
197
- content = SafeFile.read(path, max_size: MAX_SIZE)
198
- declared = content&.[](TOOL_VERSIONS_RUBY, 1)
199
- declared if declared&.match?(PLAIN_VERSION)
396
+ # The ruby tool of the mise file that wins, keyed by that file's name.
397
+ def mise_ruby(root)
398
+ MISE_FILES.each do |name|
399
+ content = read_inside(root, name)
400
+ tools = content&.[](MISE_TOOLS, 1)
401
+ declared = version_string(tools&.[](MISE_RUBY, 1))
402
+ return { name => declared } if declared
403
+ end
404
+ {}
405
+ end
406
+ private_class_method :mise_ruby
407
+
408
+ # "3.4.9" and "ruby-3.4.9" are CRuby; "jruby-9.4.8.0" names an engine
409
+ # version, not the Ruby version that engine implements, and a bare "jruby" its latest.
410
+ def version_string(declared)
411
+ return nil if declared.nil?
412
+
413
+ if (match = declared.match(ENGINE_PREFIXED)) && match[1] != "ruby"
414
+ [ nil, engine_name(match[1], match[2]) ]
415
+ elsif ENGINE_NAMES.key?(declared)
416
+ [ nil, engine_name(declared, nil) ]
417
+ else
418
+ version = declared.sub(/\Aruby-/, "")
419
+ [ version, nil ] if version.match?(PLAIN_VERSION)
420
+ end
421
+ end
422
+ private_class_method :version_string
423
+
424
+ def engine_name(engine, version)
425
+ return nil if engine.nil? || engine == "ruby"
426
+
427
+ [ ENGINE_NAMES.fetch(engine, engine), version ].compact.join(" ")
200
428
  end
201
- private_class_method :tool_versions_ruby
429
+ private_class_method :engine_name
202
430
  end
203
431
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
4
-
5
3
  module RailsAiContext
6
4
  # git's ignore-file rules (globs, anchoring, last match wins, negation) for
7
5
  # the doctor, and ripgrep's walk over a tree for the search fallback.
@@ -147,7 +145,7 @@ module RailsAiContext
147
145
  def global
148
146
  return @global if defined?(@global)
149
147
 
150
- @global = source(@root, GitIgnore.global_excludes_path(@root))
148
+ @global = source(@root, GitIgnore.global_excludes_path)
151
149
  end
152
150
  end
153
151
 
@@ -169,15 +167,19 @@ module RailsAiContext
169
167
  nil
170
168
  end
171
169
 
172
- # `core.excludesFile` as git resolves it from the app's directory, or the
173
- # default git uses when it is unset.
174
- def global_excludes_path(root)
175
- configured, status = Open3.capture2("git", "config", "--get", "core.excludesFile", chdir: root)
176
- configured = configured.to_s.strip
177
- return File.expand_path(configured) if status.success? && !configured.empty?
178
-
170
+ # `core.excludesFile` as ripgrep 15 finds it: ~/.gitconfig, then the XDG
171
+ # git config, then git's default (ignore crate `gitconfig_excludes_path`).
172
+ def global_excludes_path
179
173
  xdg = ENV["XDG_CONFIG_HOME"].to_s
180
- File.join(xdg.empty? ? File.join(Dir.home, ".config") : xdg, "git", "ignore")
174
+ config_dir = xdg.empty? ? File.join(Dir.home, ".config") : xdg
175
+ # A setting that is not UTF-8 is skipped, as ripgrep skips it.
176
+ configured = [ File.join(Dir.home, ".gitconfig"), File.join(config_dir, "git", "config") ].lazy.filter_map do |config|
177
+ path = File.file?(config) && File.binread(config)[/^\s*excludesfile\s*=\s*"?\s*(\S+?)\s*"?\s*$/i, 1]
178
+ path && path.force_encoding(Encoding::UTF_8).valid_encoding? ? path : nil
179
+ end.first
180
+ return File.expand_path(configured.gsub("~", Dir.home)) if configured
181
+
182
+ File.join(config_dir, "git", "ignore")
181
183
  rescue StandardError
182
184
  nil
183
185
  end
@@ -26,14 +26,20 @@ module RailsAiContext
26
26
  def self.format_hint(hint)
27
27
  lines = []
28
28
  lines << "### #{hint.model_name} #{hint.confidence}"
29
- lines << "**Table:** `#{hint.table_name}` (pk: `#{hint.primary_key}`)"
29
+ lines << if hint.embedded_in
30
+ "**Embedded in:** `#{hint.embedded_in}` (no collection of its own)"
31
+ elsif hint.collection
32
+ "**Collection:** `#{hint.table_name}` (key: `#{hint.primary_key}`)"
33
+ else
34
+ "**Table:** `#{hint.table_name}` (pk: `#{hint.primary_key}`)"
35
+ end
30
36
 
31
37
  if hint.columns.any?
32
38
  col_summary = hint.columns.first(10).map { |c|
33
39
  "`#{c[:name]}` #{c[:type]}#{'[]' if c[:array]}#{c[:null] == false ? ' NOT NULL' : ''}"
34
40
  }
35
41
  col_summary << "... #{hint.columns.size - 10} more" if hint.columns.size > 10
36
- lines << "**Columns:** #{col_summary.join(', ')}"
42
+ lines << "**#{hint.collection ? 'Fields' : 'Columns'}:** #{col_summary.join(', ')}"
37
43
  end
38
44
 
39
45
  if hint.associations.any?
@@ -19,10 +19,13 @@ module RailsAiContext
19
19
  model_info = models_data[model_key]
20
20
  return nil unless model_info.is_a?(Hash)
21
21
 
22
- table_name = model_info[:table_name]
23
- table_data = schema_data.dig(:tables, table_name) if table_name
22
+ mongoid = model_info[:mongoid] == true
23
+ table_name = mongoid ? model_info[:collection] : model_info[:table_name]
24
+ table_data = Payload.model_table(schema_data, model_info) if table_name && !mongoid
24
25
 
25
- columns = if table_data
26
+ columns = if mongoid
27
+ Array(model_info[:fields]).map { |field| { name: field[:name].to_s, type: field[:type] }.compact }
28
+ elsif table_data
26
29
  (table_data[:columns] || []).map do |col|
27
30
  { name: col[:name], type: col[:type], null: col[:null], array: col[:array] }.compact
28
31
  end
@@ -47,7 +50,7 @@ module RailsAiContext
47
50
  }.compact
48
51
  end
49
52
 
50
- primary_key = table_data&.dig(:primary_key) || "id"
53
+ primary_key = mongoid ? "_id" : table_data&.dig(:primary_key) || "id"
51
54
 
52
55
  # A static record already carries [STATIC]; a booted one has no
53
56
  # confidence key, and a resolved table there is reflection-confirmed.
@@ -60,7 +63,9 @@ module RailsAiContext
60
63
  associations: associations,
61
64
  validations: validations,
62
65
  primary_key: Introspectors::SchemaConventions.primary_key_label(primary_key),
63
- confidence: confidence
66
+ confidence: confidence,
67
+ collection: mongoid,
68
+ embedded_in: (model_info[:embedded_in] if mongoid)
64
69
  )
65
70
  end
66
71
  end
@@ -24,6 +24,7 @@ module RailsAiContext
24
24
  context = {
25
25
  app_name: app_name,
26
26
  ruby_version: ruby_version,
27
+ ruby_engine: ruby_engine,
27
28
  rails_version: rails_version,
28
29
  environment: environment_name,
29
30
  tier: RailsAiContext.static_tier? ? "static" : "booted",
@@ -203,6 +204,14 @@ module RailsAiContext
203
204
  GemLock.for(app.root).ruby_version || Confidence.unavailable("app declares none")
204
205
  end
205
206
 
207
+ # Nil for CRuby, else the engine and its own version ("JRuby 9.4.8.0").
208
+ def ruby_engine
209
+ return GemLock.for(app.root).ruby_engine if RailsAiContext.static_tier?
210
+ return nil if RUBY_ENGINE == "ruby"
211
+
212
+ [ GemLock::ENGINE_NAMES.fetch(RUBY_ENGINE, RUBY_ENGINE), RUBY_ENGINE_VERSION ].join(" ")
213
+ end
214
+
206
215
  def rails_version
207
216
  return Rails.version if defined?(Rails) && Rails.respond_to?(:version) && !RailsAiContext.static_tier?
208
217
 
@@ -210,7 +219,7 @@ module RailsAiContext
210
219
  # mid-sentence into files the user commits. An app can bundle railties
211
220
  # without the `rails` meta-gem and still be a Rails app at that version.
212
221
  lock = GemLock.for(app.root)
213
- lock.version("rails") || lock.version("railties") || Confidence.unavailable("app not booted")
222
+ lock.version("rails") || lock.version("railties") || Confidence.unavailable(lock.outside_gemfile ? lock.reason : "app not booted")
214
223
  end
215
224
 
216
225
  def environment_name
@@ -2,45 +2,53 @@
2
2
 
3
3
  module RailsAiContext
4
4
  module Introspectors
5
- # Discovers Action Mailbox setup: mailbox classes, routing patterns.
5
+ # Discovers Action Mailbox setup: mailbox classes and the routing that sends mail to them.
6
6
  class ActionMailboxIntrospector < Base
7
7
  extend StaticTier
8
8
  static_tier :files_only
9
9
 
10
+ BASE_FILE = "application_mailbox.rb"
11
+
10
12
  def call
13
+ mailboxes, routes = extract
11
14
  {
12
15
  installed: defined?(ActionMailbox) ? true : false,
13
- mailboxes: extract_mailboxes
16
+ mailboxes: mailboxes,
17
+ routes: routes
14
18
  }
15
19
  end
16
20
 
17
21
  private
18
22
 
19
- def extract_mailboxes
23
+ # Rails keeps one router for every mailbox, filled in load order, and the base loads first.
24
+ def extract
20
25
  dir = File.join(root, "app/mailboxes")
21
- return [] unless Dir.exist?(dir)
22
-
23
- Dir.glob(File.join(dir, "**/*.rb")).filter_map do |path|
24
- relative = path.sub("#{dir}/", "")
25
- next if relative == "application_mailbox.rb"
26
+ return [ [], [] ] unless Dir.exist?(dir)
26
27
 
27
- name = File.basename(path, ".rb").camelize
28
- ast_data = SourceIntrospector.walk(path, { mailbox: Listeners::MailboxRoutingListener })
28
+ paths = Dir.glob(File.join(dir, "**/*.rb")).sort_by { |path| [ path == File.join(dir, BASE_FILE) ? 0 : 1, path ] }
29
+ mailboxes = []
30
+ routes = []
31
+ paths.each do |path|
32
+ relative = path.delete_prefix("#{root}/")
33
+ results = SourceIntrospector.walk(path, { mailbox: Listeners::MailboxRoutingListener })[:mailbox]
29
34
 
30
- routing = ast_data[:mailbox].select { |r| r[:type] == :routing }.map do |r|
31
- { pattern: r[:pattern], action: r[:action] }
35
+ results.select { |r| r[:type] == :routing }.each do |r|
36
+ routes << { pattern: r[:pattern], mailbox: "#{r[:action].camelize}Mailbox", file: relative }
32
37
  end
38
+ next if path == File.join(dir, BASE_FILE)
33
39
 
34
- callbacks = ast_data[:mailbox].select { |r| r[:type] == :callback }.map do |r|
35
- { type: r[:callback_type], method: r[:method] }
36
- end
37
-
38
- entry = { name: name, file: relative, routing: routing }
40
+ entry = { name: path.delete_prefix("#{dir}/").delete_suffix(".rb").camelize, file: relative }
41
+ callbacks = results.select { |r| r[:type] == :callback }.map { |r| { type: r[:callback_type], method: r[:method] } }
39
42
  entry[:callbacks] = callbacks if callbacks.any?
40
- entry
43
+ mailboxes << entry
41
44
  rescue => e
42
45
  RailsAiContext.debug_fail(e, nil, label: "extract_mailboxes")
43
- end.compact.sort_by { |m| m[:name] }
46
+ end
47
+
48
+ mailboxes.each do |m|
49
+ m[:routed_from] = routes.select { |r| r[:mailbox] == m[:name] }.map { |r| r[:pattern] }
50
+ end
51
+ [ mailboxes.sort_by { |m| m[:name] }.map { |m| m.slice(:name, :file, :routed_from, :callbacks) }, routes ]
44
52
  end
45
53
  end
46
54
  end