rails-ai-context 5.32.2 → 5.34.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 (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1491 -0
  3. data/CONTEXT.md +27 -3
  4. data/README.md +16 -12
  5. data/Rakefile +6 -0
  6. data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
  7. data/config/routes.rb +2 -3
  8. data/docs/ARCHITECTURE.md +7 -5
  9. data/docs/CLI.md +79 -6
  10. data/docs/COMPATIBILITY.md +75 -1
  11. data/docs/CONFIGURATION.md +30 -11
  12. data/docs/CUSTOM_TOOLS.md +2 -1
  13. data/docs/FAQ.md +16 -6
  14. data/docs/GUIDE.md +117 -71
  15. data/docs/INTROSPECTORS.md +10 -8
  16. data/docs/QUICKSTART.md +3 -3
  17. data/docs/RECIPES.md +1 -1
  18. data/docs/SECURITY.md +109 -25
  19. data/docs/SETUP.md +30 -6
  20. data/docs/STANDALONE.md +10 -3
  21. data/docs/TOOLS.md +152 -34
  22. data/docs/TROUBLESHOOTING.md +90 -12
  23. data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
  24. data/docs/adr/0005-workspace-mcp-entries.md +39 -0
  25. data/exe/rails-ai-context +733 -104
  26. data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
  27. data/lib/rails_ai_context/app_kind.rb +18 -5
  28. data/lib/rails_ai_context/ast_cache.rb +7 -0
  29. data/lib/rails_ai_context/base_mixins.rb +8 -8
  30. data/lib/rails_ai_context/boot_manager.rb +22 -3
  31. data/lib/rails_ai_context/change_watch.rb +14 -11
  32. data/lib/rails_ai_context/cli/app_root.rb +401 -0
  33. data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
  34. data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
  35. data/lib/rails_ai_context/code_reloader.rb +115 -1
  36. data/lib/rails_ai_context/concern_membership.rb +3 -1
  37. data/lib/rails_ai_context/concern_paths.rb +8 -3
  38. data/lib/rails_ai_context/configuration.rb +41 -9
  39. data/lib/rails_ai_context/context_file_report.rb +19 -2
  40. data/lib/rails_ai_context/database_file.rb +26 -0
  41. data/lib/rails_ai_context/doctor.rb +1122 -184
  42. data/lib/rails_ai_context/engine.rb +18 -1
  43. data/lib/rails_ai_context/erb_source.rb +19 -0
  44. data/lib/rails_ai_context/executable.rb +21 -0
  45. data/lib/rails_ai_context/facts_formatter.rb +5 -2
  46. data/lib/rails_ai_context/file_walk.rb +61 -10
  47. data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
  48. data/lib/rails_ai_context/fingerprinter.rb +40 -12
  49. data/lib/rails_ai_context/gem_lock.rb +186 -27
  50. data/lib/rails_ai_context/git_ignore.rb +22 -0
  51. data/lib/rails_ai_context/install/ai_tool.rb +31 -5
  52. data/lib/rails_ai_context/install/cleanup.rb +21 -0
  53. data/lib/rails_ai_context/install/program.rb +209 -35
  54. data/lib/rails_ai_context/install/selection_record.rb +258 -97
  55. data/lib/rails_ai_context/install/surface.rb +6 -1
  56. data/lib/rails_ai_context/install/validation_hook.rb +282 -0
  57. data/lib/rails_ai_context/install/workspace.rb +166 -0
  58. data/lib/rails_ai_context/install_mode.rb +71 -13
  59. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
  60. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
  61. data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
  63. data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
  65. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
  66. data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
  67. data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
  68. data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
  69. data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
  70. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
  71. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
  72. data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
  73. data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
  74. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
  75. data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
  76. data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
  77. data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
  78. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
  79. data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
  80. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
  81. data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
  82. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
  83. data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
  84. data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
  85. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
  86. data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
  87. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
  88. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
  90. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
  91. data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
  92. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
  93. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
  94. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
  95. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
  96. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
  97. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
  98. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
  99. data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
  100. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
  101. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
  102. data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
  103. data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
  104. data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
  105. data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
  106. data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
  107. data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
  108. data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
  109. data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
  110. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
  111. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
  112. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
  113. data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
  114. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
  115. data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
  116. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
  117. data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
  118. data/lib/rails_ai_context/live_reload.rb +20 -20
  119. data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
  120. data/lib/rails_ai_context/mcp_edge.rb +182 -32
  121. data/lib/rails_ai_context/middleware.rb +5 -1
  122. data/lib/rails_ai_context/migration_status.rb +88 -25
  123. data/lib/rails_ai_context/output_guard.rb +48 -2
  124. data/lib/rails_ai_context/package_json.rb +1 -1
  125. data/lib/rails_ai_context/path_resolver.rb +46 -13
  126. data/lib/rails_ai_context/payload.rb +92 -8
  127. data/lib/rails_ai_context/pending_migrations.rb +5 -1
  128. data/lib/rails_ai_context/portable_path.rb +3 -2
  129. data/lib/rails_ai_context/presets.rb +15 -11
  130. data/lib/rails_ai_context/redaction.rb +262 -51
  131. data/lib/rails_ai_context/resources.rb +55 -20
  132. data/lib/rails_ai_context/route_coverage.rb +8 -0
  133. data/lib/rails_ai_context/safe_file.rb +14 -1
  134. data/lib/rails_ai_context/safe_path.rb +101 -4
  135. data/lib/rails_ai_context/schema_adapter.rb +8 -0
  136. data/lib/rails_ai_context/schema_version.rb +9 -0
  137. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
  138. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
  139. data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
  140. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
  141. data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
  142. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
  143. data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
  144. data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
  145. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
  146. data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
  147. data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
  148. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
  149. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
  150. data/lib/rails_ai_context/server.rb +304 -71
  151. data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
  152. data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
  153. data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
  154. data/lib/rails_ai_context/tools/base_tool.rb +367 -46
  155. data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
  156. data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
  157. data/lib/rails_ai_context/tools/diagnose.rb +218 -11
  158. data/lib/rails_ai_context/tools/generate_test.rb +339 -39
  159. data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
  160. data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
  161. data/lib/rails_ai_context/tools/get_concern.rb +91 -27
  162. data/lib/rails_ai_context/tools/get_config.rb +13 -4
  163. data/lib/rails_ai_context/tools/get_context.rb +71 -14
  164. data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
  165. data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
  166. data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
  167. data/lib/rails_ai_context/tools/get_env.rb +18 -9
  168. data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
  169. data/lib/rails_ai_context/tools/get_gems.rb +1 -1
  170. data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
  171. data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
  172. data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
  173. data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
  174. data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
  175. data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
  176. data/lib/rails_ai_context/tools/get_routes.rb +29 -8
  177. data/lib/rails_ai_context/tools/get_schema.rb +52 -19
  178. data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
  179. data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
  180. data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
  181. data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
  182. data/lib/rails_ai_context/tools/get_view.rb +14 -14
  183. data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
  184. data/lib/rails_ai_context/tools/onboard.rb +52 -8
  185. data/lib/rails_ai_context/tools/performance_check.rb +5 -3
  186. data/lib/rails_ai_context/tools/query.rb +914 -86
  187. data/lib/rails_ai_context/tools/read_logs.rb +69 -15
  188. data/lib/rails_ai_context/tools/review_changes.rb +215 -53
  189. data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
  190. data/lib/rails_ai_context/tools/safe_call.rb +35 -14
  191. data/lib/rails_ai_context/tools/search_code.rb +243 -67
  192. data/lib/rails_ai_context/tools/search_docs.rb +4 -4
  193. data/lib/rails_ai_context/tools/security_scan.rb +143 -33
  194. data/lib/rails_ai_context/tools/session_context.rb +39 -5
  195. data/lib/rails_ai_context/tools/validate.rb +259 -40
  196. data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
  197. data/lib/rails_ai_context/version.rb +1 -1
  198. data/lib/rails_ai_context/vfs.rb +67 -40
  199. data/lib/rails_ai_context/view_file.rb +41 -3
  200. data/lib/rails_ai_context/watcher.rb +51 -13
  201. data/lib/rails_ai_context.rb +21 -0
  202. data/server.json +1 -1
  203. metadata +14 -1
@@ -23,21 +23,53 @@ module RailsAiContext
23
23
 
24
24
  INLINE_BLOCK = "[inline_block]"
25
25
 
26
+ # turbo-rails' broadcast macros are callbacks too: each declares a
27
+ # commit callback per event, a lambda running the method named here
28
+ # (Turbo::Broadcastable::ClassMethods).
29
+ BROADCASTS = {
30
+ broadcasts: { "after_create_commit" => "broadcast_action_later_to", "after_update_commit" => "broadcast_replace_later",
31
+ "after_destroy_commit" => "broadcast_remove" },
32
+ broadcasts_to: { "after_create_commit" => "broadcast_action_later_to", "after_update_commit" => "broadcast_replace_later_to",
33
+ "after_destroy_commit" => "broadcast_remove_to" },
34
+ broadcasts_refreshes: { "after_create_commit" => "broadcast_refresh_later_to", "after_update_commit" => "broadcast_refresh_later",
35
+ "after_destroy_commit" => "broadcast_refresh" },
36
+ broadcasts_refreshes_to: { "after_commit" => "broadcast_refresh_later_to" }
37
+ }.freeze
38
+
26
39
  # Macros that call args.extract_options!, so a trailing braced hash is options, not a filter.
27
40
  EXTRACTS_OPTIONS = %i[
28
41
  before_validation after_validation after_commit after_rollback
29
42
  after_create_commit after_update_commit after_destroy_commit after_save_commit
30
43
  ].to_set.freeze
31
44
 
45
+ # `before_<event>` on a chain `define_model_callbacks` makes; outside the file that defines it
46
+ # (Paperclip's has_attached_file), only the booted model's chains say it is one.
47
+ MODEL_CALLBACK = /\A(?:before|after|around)_(\w+)\z/
48
+
49
+ # `library_chains: true` also keeps a callback on a chain the file does not define, under
50
+ # `library_chain`, for a caller that checks the model holds that chain.
51
+ def initialize(library_chains: false)
52
+ super()
53
+ @library_chains = library_chains
54
+ end
55
+
32
56
  def on_call_node_enter(node)
33
57
  return record_skip(node) if node.name == :skip_callback && in_scope?(node)
34
- return unless CALLBACK_METHODS.include?(node.name) && in_scope?(node)
58
+ return record_broadcasts(node) if BROADCASTS.key?(node.name) && in_scope?(node)
59
+ return record_defined(node) if node.name == :define_model_callbacks && in_scope?(node)
60
+
61
+ standard = CALLBACK_METHODS.include?(node.name)
62
+ chain = MODEL_CALLBACK.match(node.name.to_s)&.[](1) unless standard
63
+ return unless (standard || chain) && in_scope?(node)
64
+
65
+ library_chain = chain unless defined_here.include?(node.name)
66
+ return if library_chain && !@library_chains
35
67
 
36
68
  # Sources, not literals: a lambda condition reads as the line the
37
69
  # file holds rather than as a marker.
38
70
  hash = options_hash(node)
39
71
  options = scope_options(receiver_name(node)).merge(extract_keyword_sources(node)).merge(hash_sources(hash))
40
- emit(node, resolve_callback_types(node.name, options), callback_targets(node, hash), options)
72
+ emit(node, resolve_callback_types(node.name, options), callback_targets(node, hash), options, library_chain: library_chain)
41
73
  end
42
74
 
43
75
  ON_EVENT = "after_commit_on_"
@@ -60,6 +92,13 @@ module RailsAiContext
60
92
  type.to_s.start_with?(ON_EVENT)
61
93
  end
62
94
 
95
+ # What a broadcast macro's callbacks are listed as. Not a bare name:
96
+ # the model has no method of it, and Rails keeps each of its lambdas
97
+ # where it merges two callbacks of one method name.
98
+ def self.broadcast_target(macro)
99
+ "#{macro} (turbo-rails)"
100
+ end
101
+
63
102
  # The event such a type names, `"create"`, or nil.
64
103
  def self.event(type)
65
104
  type.to_s.delete_prefix(ON_EVENT) if names_event?(type)
@@ -117,10 +156,20 @@ module RailsAiContext
117
156
  end
118
157
  end
119
158
 
120
- def emit(node, callback_types, targets, options)
159
+ def defined_here
160
+ @defined_here ||= Set.new
161
+ end
162
+
163
+ def record_defined(node)
164
+ only = Array(extract_keyword_options(node)[:only]).map(&:to_s)
165
+ kinds = only.empty? ? KINDS : KINDS & only
166
+ extract_symbol_args(node).each { |event| kinds.each { |kind| defined_here << :"#{kind}_#{event}" } }
167
+ end
168
+
169
+ def emit(node, callback_types, targets, options, library_chain: nil)
121
170
  targets.each do |method_name, confidence|
122
171
  callback_types.each do |callback_type|
123
- @results << {
172
+ record = {
124
173
  # The declared macro, so a renderer can print what the file
125
174
  # says instead of the resolved type.
126
175
  name: node.name.to_s,
@@ -131,10 +180,35 @@ module RailsAiContext
131
180
  location: node.location.start_line,
132
181
  confidence: confidence
133
182
  }
183
+ # A block has no method whose body could be read; its declaration is its source.
184
+ record.merge!(declaration(node)) if method_name == INLINE_BLOCK
185
+ record[:library_chain] = library_chain if library_chain
186
+ @results << record
134
187
  end
135
188
  end
136
189
  end
137
190
 
191
+ def record_broadcasts(node)
192
+ BROADCASTS[node.name].each do |type, runs|
193
+ @results << {
194
+ name: node.name.to_s,
195
+ type: type,
196
+ method: self.class.broadcast_target(node.name),
197
+ # Its options are the broadcast's rendering, never a callback condition.
198
+ options: {},
199
+ runs: runs,
200
+ owner: @owner_stack.dup,
201
+ location: node.location.start_line,
202
+ confidence: confidence_for(node)
203
+ }.merge(declaration(node))
204
+ end
205
+ end
206
+
207
+ # The call as the file writes it, at the file's indentation.
208
+ def declaration(node)
209
+ { source: (" " * node.location.start_column) + NodeSource.text(node), end_location: node.location.end_line }
210
+ end
211
+
138
212
  # `after_commit on: :create` is the after_commit_on_create type. One
139
213
  # declaration for several events stays one callback, its on: kept.
140
214
  def resolve_callback_types(name, options)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pathname"
4
+ require "prism"
4
5
 
5
6
  module RailsAiContext
6
7
  module Introspectors
@@ -80,11 +80,20 @@ module RailsAiContext
80
80
  # `ENV.fetch("PORT", defaults[:port])` has a default, but naming
81
81
  # it `defaults[:port]` puts a Ruby expression where a reader
82
82
  # expects something to copy into a .env file.
83
- default: literal_value(args[1]),
83
+ default: args.size > 1 ? literal_value(args[1]) : block_value(node.block),
84
84
  location: node.location.start_line
85
85
  }
86
86
  end
87
87
 
88
+ # `ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" }`: a block whose
89
+ # one statement is a literal is that default, as a second argument is.
90
+ def block_value(block)
91
+ statements = block.body if block.is_a?(Prism::BlockNode)
92
+ return unless statements.is_a?(Prism::StatementsNode) && statements.body.size == 1
93
+
94
+ literal_value(statements.body.first)
95
+ end
96
+
88
97
  def literal_value(node)
89
98
  case node
90
99
  when Prism::StringNode then node.unescaped
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ module Listeners
6
+ # The blocks a class body evaluates on another receiver (`translation_class.class_eval do`),
7
+ # each with the source of every outermost call, by the line it starts on.
8
+ class ForeignEvalsListener < BaseListener
9
+ def initialize
10
+ super
11
+ @def_depth = 0
12
+ @open = []
13
+ @conditions = []
14
+ end
15
+
16
+ # An if or unless condition (`validates :x unless Rails.env.test?`) is not a declaration.
17
+ def on_if_node_enter(node)
18
+ @conditions << node.predicate.location
19
+ end
20
+
21
+ def on_unless_node_enter(node)
22
+ @conditions << node.predicate.location
23
+ end
24
+
25
+ def on_def_node_enter(_node)
26
+ @def_depth += 1
27
+ end
28
+
29
+ def on_def_node_leave(_node)
30
+ @def_depth -= 1
31
+ end
32
+
33
+ def on_call_node_enter(node)
34
+ @open.each { |_, block| record(block, node) }
35
+ return unless @def_depth.zero? && CallSiteExpansion.foreign_eval?(node)
36
+
37
+ body = node.block.location
38
+ block = { receiver: node.receiver.slice, lines: body.start_line..body.end_line, span: node.location.length, calls: {} }
39
+ @results << block
40
+ @open << [ node, block ]
41
+ end
42
+
43
+ def on_call_node_leave(node)
44
+ @open.pop if @open.last&.first.equal?(node)
45
+ end
46
+
47
+ private
48
+
49
+ # A call inside one already recorded on its line (`-> { order(:id) }`) is part of that one.
50
+ def record(block, node)
51
+ return if @conditions.any? { |condition| node.location.start_offset.between?(condition.start_offset, condition.end_offset - 1) }
52
+
53
+ calls = (block[:calls][node.location.start_line] ||= [])
54
+ return if calls.any? { |call| call[:range].cover?(node.location.start_offset) }
55
+
56
+ calls << { range: node.location.start_offset...node.location.end_offset, source: CallSiteExpansion.call_source(node) }
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -30,19 +30,24 @@ module RailsAiContext
30
30
  STORE_MACROS = %i[store store_accessor].to_set.freeze
31
31
 
32
32
  BROADCAST_MACROS = %i[
33
- broadcasts broadcasts_to broadcasts_refreshes_to
33
+ broadcasts broadcasts_to broadcasts_refreshes broadcasts_refreshes_to
34
34
  ].to_set.freeze
35
35
 
36
36
  # Model gems' class macros, each listed as written; aasm's block is read in full.
37
+ # `devise` names the modules a model turns on, which no other line says.
37
38
  GEM_MACROS = %i[
39
+ devise
38
40
  has_paper_trail audited acts_as_paranoid friendly_id
39
41
  mount_uploader mount_uploaders monetize
40
42
  pg_search_scope multisearchable searchkick
41
43
  acts_as_list acts_as_tenant multi_tenant acts_as_taggable acts_as_taggable_on
42
44
  has_ancestry acts_as_nested_set has_closure_tree acts_as_tree
43
- state_machine workflow
45
+ state_machine workflow has_attached_file
44
46
  ].to_set.freeze
45
47
 
48
+ # Listed by the macro and its first argument: the options are lambdas that read as noise on one line.
49
+ NAMED_ONLY_MACROS = %i[has_attached_file].to_set.freeze
50
+
46
51
  def initialize
47
52
  super
48
53
  @def_depth = 0
@@ -160,8 +165,12 @@ module RailsAiContext
160
165
  end
161
166
 
162
167
  def record_gem_macro(node)
163
- text = one_line_source(node, upto: node.block&.location&.start_offset)
164
- text = text.gsub(/\s+/, " ").strip
168
+ first = node.arguments&.arguments&.first
169
+ text = if NAMED_ONLY_MACROS.include?(node.name) && first
170
+ "#{node.name} #{first.slice}"
171
+ else
172
+ one_line_source(node, upto: node.block&.location&.start_offset).gsub(/\s+/, " ").strip
173
+ end
165
174
  adds = monetized_names(node) if node.name == :monetize
166
175
  @results << { macro: :gem_macro, name: node.name, text: text, adds: adds.presence,
167
176
  location: node.location.start_line, confidence: confidence_for(node) }.compact
@@ -74,6 +74,7 @@ module RailsAiContext
74
74
  if node.name == :rename_table
75
75
  result[:new_name] = literal_string(args[1])
76
76
  end
77
+ result.merge!(default_fields(node, key: true)) if action == :create_table
77
78
 
78
79
  @results << result
79
80
  end
@@ -96,7 +97,7 @@ module RailsAiContext
96
97
  when :rename_column
97
98
  result[:new_name] = literal_string(args[2])
98
99
  when :change_column
99
- result[:column_type] = literal_string(args[2])
100
+ result.merge!(column_type: literal_string(args[2]), **default_fields(node))
100
101
  when :change_column_null
101
102
  # The nullability is the third positional, not a keyword.
102
103
  result[:null] = boolean_value(args[2])
@@ -99,12 +99,13 @@ module RailsAiContext
99
99
  when :remove_index then result[:columns] = literal_strings(positional.first)
100
100
  when :rename_column then result.merge!(column: names[0], new_name: names[1])
101
101
  when :rename_index then result.merge!(old_name: names[0], new_name: names[1])
102
- when :change_column then result.merge!(column: names[0], column_type: names[1])
102
+ when :change_column
103
+ result.merge!(column: names[0], column_type: names[1], **default_fields(node))
103
104
  when :change_column_null then result.merge!(column: names[0], null: boolean_value(positional[1]))
104
105
  when :change_column_default
105
106
  result[:column] = names[0]
106
107
  result[:new_default] = extract_value(positional[1]) if positional[1]
107
- when :add_timestamps then result.merge!(default_source: default_source(node), default_proc: proc_default?(node))
108
+ when :add_timestamps then result.merge!(default_fields(node))
108
109
  when :add_check_constraint, :remove_check_constraint then result[:expression] = names[0]
109
110
  end
110
111
  result
@@ -24,8 +24,11 @@ module RailsAiContext
24
24
  # `direct` and `resolve` define URL helpers and draw no route.
25
25
  URL_HELPER_MACROS = %i[direct resolve].freeze
26
26
  # Calls that draw routes; an unknown block that makes none of them configures a gem macro.
27
- ROUTE_METHODS = (VERB_METHODS + %i[match namespace scope resources resource member collection new shallow
27
+ ROUTE_METHODS = (VERB_METHODS + %i[match namespace scope resources resource member collection new nested shallow
28
28
  concern concerns with_options controller mount root draw devise_for]).freeze
29
+ # Blocks that draw their routes where they stand: Rails' constraints and defaults, Devise's, and route_translator's.
30
+ # Any other method's block may scope its routes in a way the walk cannot see.
31
+ IN_PLACE_BLOCKS = %i[constraints defaults devise_scope as authenticate authenticated unauthenticated localized].freeze
29
32
  # Statement-level calls that draw nothing into the table.
30
33
  NON_ROUTING = %i[require require_relative puts p pp print warn raise default_url_options extend include].freeze
31
34
 
@@ -38,9 +41,11 @@ module RailsAiContext
38
41
  # `route_set` answers { prefix:, name_prefix: } for an app class that draws routes.
39
42
  # `names` is the set of route names taken so far, shared by every file of one table.
40
43
  # `multi_path`: the app's Rails draws every path of `get "/a", "/b"`; 8.1 raises instead.
41
- def initialize(scope: [], route_set: nil, names: Set.new, multi_path: false)
44
+ # `api_only`: the app sets config.api_only, so its own table draws no new or edit route.
45
+ def initialize(scope: [], route_set: nil, names: Set.new, multi_path: false, api_only: false)
42
46
  super()
43
47
  @multi_path = multi_path
48
+ @api_only = api_only
44
49
  @stack = scope.map { |frame| frame.merge(node: nil) }
45
50
  @route_set = route_set
46
51
  @concern_blocks = {}
@@ -93,6 +98,8 @@ module RailsAiContext
93
98
  when :member then enter_member_collection(node, :member)
94
99
  when :collection then enter_member_collection(node, :collection)
95
100
  when :new then enter_member_collection(node, :new)
101
+ # A resource's frame already holds its nested scope, which a direct child draws under too.
102
+ when :nested then push_frame(node, suppress: current_resource.nil?) if node.block
96
103
  when :shallow then push_frame(node, shallow: true) if node.block
97
104
  when :concern then define_concern(node)
98
105
  when :concerns then apply_concerns(node)
@@ -107,20 +114,25 @@ module RailsAiContext
107
114
  else
108
115
  if statement && node.block
109
116
  # An empty block (a commented-out `constraints do`) configures nothing.
110
- push_frame(node, unknown_block: @routing_calls, pending: []) if node.block.body
117
+ push_frame(node, unknown_block: @routing_calls, pending: [], suppress: !IN_PLACE_BLOCKS.include?(node.name)) if node.block.body
111
118
  elsif statement && !NON_ROUTING.include?(node.name)
112
119
  call_method_or_count(node)
113
120
  end
114
121
  end
115
122
  end
116
123
 
117
- # An unknown block that draws no route is a gem macro (`use_doorkeeper do`), counted once.
124
+ # An unknown block that draws no route is a gem macro (`use_doorkeeper do`), counted once,
125
+ # and so is one whose routes it does not draw in place.
118
126
  def on_call_node_leave(node)
119
127
  return unless @stack.last && @stack.last[:node].equal?(node)
120
128
 
121
129
  frame = @stack.pop
122
130
  return unless frame[:unknown_block]
123
- return frame[:pending].each { |call| emit_dynamic(call) } if frame[:unknown_block] != @routing_calls
131
+ if frame[:unknown_block] != @routing_calls
132
+ return emit_dynamic(node) if frame[:suppress]
133
+
134
+ return frame[:pending].each { |call| emit_dynamic(call) }
135
+ end
124
136
 
125
137
  outer = open_unknown_block
126
138
  outer ? outer[:pending] << node : emit_dynamic(node)
@@ -248,6 +260,12 @@ module RailsAiContext
248
260
  @stack.reverse.find { |f| f[:route_set] }&.dig(:route_set)
249
261
  end
250
262
 
263
+ # The app's config.api_only reaches its own route set only: an engine's
264
+ # table, or a route set class the app draws, keeps its own setting.
265
+ def api_only_table?
266
+ @api_only && @stack.none? { |f| f[:engine] || f[:route_set] }
267
+ end
268
+
251
269
  # The controller a route without one of its own takes: the innermost
252
270
  # `scope(controller:)` or resource, else Rails' module path alone.
253
271
  def current_controller
@@ -610,7 +628,7 @@ module RailsAiContext
610
628
  member = layout[:member_path]
611
629
  member_name = layout[:member_name]
612
630
  controller = resource_controller(name, opts, singular: singular)
613
- actions = requested_actions(singular ? SINGULAR_ACTIONS : PLURAL_ACTIONS, opts)
631
+ actions = requested_actions(singular ? SINGULAR_ACTIONS : PLURAL_ACTIONS, opts, api_only: api_only_table?)
614
632
 
615
633
  actions.each do |action|
616
634
  case action
@@ -751,7 +769,7 @@ module RailsAiContext
751
769
  # @return [Array<[String, Boolean]>] each path, and whether it names an action
752
770
  def literal_paths(node)
753
771
  args = (node.arguments&.arguments || []).select { |a| a.is_a?(Prism::StringNode) || a.is_a?(Prism::SymbolNode) }
754
- args.sort_by { |a| a.is_a?(Prism::StringNode) ? 0 : 1 }.map { |a| [ a.unescaped, a.is_a?(Prism::SymbolNode) ] }
772
+ args.partition { |a| a.is_a?(Prism::StringNode) }.flatten.map { |a| [ a.unescaped, a.is_a?(Prism::SymbolNode) ] }
755
773
  end
756
774
 
757
775
  def emit_verb_path(node, verb, segment, action_given, opts, rocket_key)
@@ -949,8 +967,11 @@ module RailsAiContext
949
967
  end
950
968
  end
951
969
 
952
- def requested_actions(all, opts)
970
+ # Rails' Resource#default_actions: an api_only route set leaves out new
971
+ # and edit, unless `only:` asks for them by name.
972
+ def requested_actions(all, opts, api_only: false)
953
973
  actions = all
974
+ actions -= %i[new edit] if api_only && !opts[:only]
954
975
  actions &= Array(opts[:only]).map(&:to_sym) if opts[:only]
955
976
  actions -= Array(opts[:except]).map(&:to_sym) if opts[:except]
956
977
  actions
@@ -168,6 +168,7 @@ module RailsAiContext
168
168
  # id: false / id: :uuid / primary_key: ... decide whether the
169
169
  # implicit primary-key column exists and what to call it.
170
170
  options: extract_keyword_options(node),
171
+ **default_fields(node, key: true),
171
172
  location: node.location.start_line
172
173
  }
173
174
  end
@@ -321,8 +322,7 @@ module RailsAiContext
321
322
  options: options,
322
323
  # A proc default (`default: -> { "now()" }`) has no literal value,
323
324
  # so keep its source for callers that report defaults verbatim.
324
- default_source: default_source(node),
325
- default_proc: proc_default?(node),
325
+ **default_fields(node),
326
326
  location: node.location.start_line
327
327
  }
328
328
  @results.last[:virtual] = true if virtual
@@ -12,27 +12,52 @@ module RailsAiContext
12
12
  # in a block, not in a LambdaNode.
13
13
  LAMBDA_METHODS = %i[lambda proc].to_set.freeze
14
14
 
15
+ # A block these run once per element of the list they are sent to.
16
+ LOOPS = %i[each each_with_index].to_set.freeze
17
+
18
+ def initialize
19
+ super
20
+ @lists = {}
21
+ @loops = []
22
+ end
23
+
15
24
  def on_call_node_enter(node)
25
+ enter_loop(node)
16
26
  return record_default_scope(node) if node.name == :default_scope && in_scope?(node)
17
27
  return unless node.name == :scope && in_scope?(node)
18
28
 
19
29
  name = extract_first_symbol(node)
20
- return if name == "[INFERRED]"
30
+ names = name == "[INFERRED]" ? loop_names(node.arguments&.arguments&.first) : [ name ]
31
+ return unless names
21
32
 
22
33
  callable = scope_body_node(node)
23
34
  body = callable ? lambda_body_source(callable) : nil
24
35
 
25
- @results << {
26
- name: name.to_s,
27
- body: body,
28
- required_params: callable ? lambda_required_params(callable) : [],
29
- location: node.location.start_line,
30
- # A lambda body sliced verbatim from source IS the scope's ground
31
- # truth; only scopes whose body can't be extracted (block form,
32
- # metaprogrammed) are heuristic.
33
- confidence: body ? Confidence::VERIFIED : Confidence::INFERRED,
34
- owner: @owner_stack.dup
35
- }
36
+ names.each do |scope_name|
37
+ @results << {
38
+ name: scope_name.to_s,
39
+ body: body,
40
+ required_params: callable ? lambda_required_params(callable) : [],
41
+ location: node.location.start_line,
42
+ # A lambda body sliced verbatim from source IS the scope's ground
43
+ # truth; only scopes whose body can't be extracted (block form,
44
+ # metaprogrammed) are heuristic, and so is a name a loop gives.
45
+ confidence: body && name != "[INFERRED]" ? Confidence::VERIFIED : Confidence::INFERRED,
46
+ owner: @owner_stack.dup
47
+ }
48
+ end
49
+ end
50
+
51
+ def on_call_node_leave(node)
52
+ @loops.pop if @loops.last && @loops.last[:node].equal?(node)
53
+ super if defined?(super)
54
+ end
55
+
56
+ # A list a loop below can name by its constant.
57
+ def on_constant_write_node_enter(node)
58
+ values = literal_list(node.value)
59
+ @lists[node.name] = values if values
60
+ super
36
61
  end
37
62
 
38
63
  # Rails calls a `def self.default_scope` the model defines, as it does the macro's lambda.
@@ -55,6 +80,57 @@ module RailsAiContext
55
80
 
56
81
  private
57
82
 
83
+ # `KINDS.each { |kind| scope kind, -> { where(kind: kind) } }` declares a
84
+ # scope per element. Over a literal list - written inline, or a constant
85
+ # this file assigns one - the names are known before anything runs.
86
+ def enter_loop(node)
87
+ return unless LOOPS.include?(node.name) && node.arguments.nil? && node.block.is_a?(Prism::BlockNode)
88
+
89
+ param = node.block.parameters&.parameters&.requireds&.first
90
+ return unless param.is_a?(Prism::RequiredParameterNode)
91
+
92
+ receiver = node.receiver
93
+ values = receiver.is_a?(Prism::ConstantReadNode) ? @lists[receiver.name] : literal_list(receiver)
94
+ @loops.push({ node: node, param: param.name, values: values }) if values
95
+ end
96
+
97
+ # `%w[a b]`, `%i[a b]` or `[:a, "b"]`, frozen or not; nil for anything else.
98
+ def literal_list(node)
99
+ node = node.receiver while node.is_a?(Prism::CallNode) && node.name == :freeze && node.arguments.nil? && node.receiver
100
+ return nil unless node.is_a?(Prism::ArrayNode)
101
+
102
+ values = node.elements.map { |element| literal_string(element) }
103
+ values unless values.any?(&:nil?)
104
+ end
105
+
106
+ # One name per element of the innermost loop the name reads, or nil when it reads none.
107
+ def loop_names(arg)
108
+ @loops.reverse_each do |loop|
109
+ names = loop[:values].map { |value| loop_name(arg, loop[:param], value) }
110
+ return names.uniq unless names.any?(&:nil?)
111
+ end
112
+ nil
113
+ end
114
+
115
+ # The name an element gives: the block's variable as written, `.to_sym`'d,
116
+ # or interpolated into a symbol or a string.
117
+ def loop_name(arg, param, value)
118
+ case arg
119
+ when Prism::LocalVariableReadNode
120
+ value if arg.name == param
121
+ when Prism::CallNode
122
+ loop_name(arg.receiver, param, value) if %i[to_sym to_s intern].include?(arg.name) && arg.arguments.nil?
123
+ when Prism::InterpolatedSymbolNode, Prism::InterpolatedStringNode
124
+ parts = arg.parts.map do |part|
125
+ next part.unescaped if part.is_a?(Prism::StringNode)
126
+
127
+ statements = part.statements&.body if part.is_a?(Prism::EmbeddedStatementsNode)
128
+ loop_name(statements.first, param, value) if statements&.size == 1
129
+ end
130
+ parts.join unless parts.any?(&:nil?)
131
+ end
132
+ end
133
+
58
134
  # Named `default_scope` and marked `default`: it is no method to call,
59
135
  # and Rails stacks every one onto each query the model runs.
60
136
  def record_default_scope(node)
@@ -86,7 +86,7 @@ module RailsAiContext
86
86
  def middleware_files(middleware_dir)
87
87
  return [] unless Dir.exist?(middleware_dir)
88
88
 
89
- Dir.glob(File.join(middleware_dir, "**/*.rb")).sort.filter_map { |path| describe_middleware(path) }
89
+ FileWalk.glob(middleware_dir, "**/*.rb", root: root).filter_map { |path| describe_middleware(path) }
90
90
  end
91
91
 
92
92
  # The name comes from the declaration: a class under lib/middleware may
@@ -199,7 +199,7 @@ module RailsAiContext
199
199
  # often as an initializer does.
200
200
  def middleware_config_files
201
201
  PathResolver.initializer_paths(root) +
202
- Dir.glob(File.join(root, "config/environments/*.rb")).sort +
202
+ Dir.glob(File.join(root, "config/environments/*.rb")).sort.reject { |path| PathResolver.linked_out?(path, root) } +
203
203
  [ File.join(root, "config/application.rb") ].select { |path| File.file?(path) }
204
204
  end
205
205
 
@@ -20,6 +20,8 @@ module RailsAiContext
20
20
  # list would read as "up to date".
21
21
  pending = pending_migrations
22
22
  info[:pending] = pending if pending
23
+ secondary = secondary_databases
24
+ info[:secondary_databases] = secondary if secondary.any?
23
25
  info
24
26
  end
25
27
 
@@ -72,6 +74,31 @@ module RailsAiContext
72
74
  RailsAiContext.debug_fail(e, nil, label: "current_schema_version")
73
75
  end
74
76
 
77
+ # The other databases `db:migrate` runs here, each from its own
78
+ # migrations_paths, keyed by database.yml name. Pending is what the
79
+ # database's dump records, as the schema section reads that database;
80
+ # one that shares the primary's directories adds no files of its own.
81
+ def secondary_databases
82
+ dumps = SchemaDumpPath.secondaries(root)
83
+ RailsAiContext::DatabaseYml.task_secondaries(root).each_with_object({}) do |(name, entry), found|
84
+ dirs = MigrationReplay.configured_dirs(root, entry)
85
+ next if dirs.nil? || dirs == migrate_dir
86
+
87
+ files = RailsAiContext::PendingMigrations.migration_files(dirs, root: root)
88
+ next if files.empty?
89
+
90
+ format, dump = dumps[name]
91
+ pending = RailsAiContext::PendingMigrations.for(migrate_dir: dirs, applied: RailsAiContext::SchemaVersion.recorded(format, dump), root: root)
92
+ found[name] = {
93
+ total: files.size,
94
+ migrations_paths: dirs.select { |dir| Dir.exist?(dir) }.map { |dir| dir.delete_prefix("#{root.chomp('/')}/") },
95
+ pending: pending
96
+ }.compact
97
+ end
98
+ rescue => e
99
+ RailsAiContext.debug_fail(e, {}, label: "secondary_databases")
100
+ end
101
+
75
102
  def migration_stats
76
103
  return {} if all_migrations.empty?
77
104
 
@@ -72,7 +72,7 @@ module RailsAiContext
72
72
  rescues.none? { |r| r.cover?(call.location.start_line) }
73
73
  end
74
74
  items = own.map { |e| [ e[:location], 0, e ] } + called.map { |c| [ c.location.start_line, 1, c ] }
75
- items.sort_by { |line, kind, _| [ line, kind ] }.each do |_, kind, item|
75
+ items.sort_by.with_index { |(line, kind, _), i| [ line, kind, i ] }.each do |_, kind, item|
76
76
  if kind.zero?
77
77
  expanded << item.merge(location: anchor || item[:location], order: (order += 1))
78
78
  else