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
@@ -13,147 +13,430 @@ module RailsAiContext
13
13
  def initialize(include_initialize: false)
14
14
  super()
15
15
  @include_initialize = include_initialize
16
- @visibility_stack = [ :public ]
17
- @in_singleton_class = false
18
- @singleton_depth = 0
19
- @inline_visibility_stack = [ {} ] # stack of { method_name => visibility }
16
+ @results = Results.new
17
+ @frames = [ Frame.new(:body, :public, {}) ]
20
18
  @owner_stack = []
21
- # `class_methods do` and `included do` bodies, keyed by their block
22
- # node so the block's own enter/leave can open and close a scope.
23
- @concern_blocks = {}.compare_by_identity
19
+ # Blocks that open a body of their own, keyed by their block node so
20
+ # the block's own enter/leave can open and close the frame.
21
+ @scoped_blocks = {}.compare_by_identity
24
22
  @open_blocks = []
25
- @scope_stack = []
23
+ @def_depth = 0
24
+ @builders = []
25
+ # Each entry's parameters as written, which an alias of it takes on.
26
+ @param_slices = {}.compare_by_identity
26
27
  end
27
28
 
28
- # Reset visibility when entering a new class/module scope
29
+ # The methods found, plus the span of every def walked, listed or not: a call
30
+ # inside a skipped `initialize` still runs only when that method does.
31
+ class Results < Array
32
+ def def_spans
33
+ @def_spans ||= []
34
+ end
35
+ end
36
+
37
+ # One visibility scope: a class or module body, a `class << self`
38
+ # body, or a block Rails evaluates as a module body. `marks` holds the
39
+ # inline `private :x` forms, keyed by [scope, name].
40
+ Frame = Struct.new(:kind, :visibility, :marks)
41
+
42
+ CLASS_SCOPE_FRAMES = %i[singleton class_methods].freeze
43
+ # A scope's or an association's block extends the relation, and
44
+ # `concern` builds a module the class does not include: a def in
45
+ # one is no method of the class.
46
+ BLOCK_FRAMES = {
47
+ class_methods: :class_methods, included: :included, concerning: :body,
48
+ scope: :extension, has_many: :extension, has_and_belongs_to_many: :extension,
49
+ has_one: :extension, belongs_to: :extension, concern: :extension
50
+ }.freeze
51
+
52
+ # Each defines a public method whatever `private` section it sits in;
53
+ # only Rails' `private: true` makes one private.
54
+ DELEGATORS = %i[delegate def_delegators def_instance_delegators def_delegator def_instance_delegator instance_delegate].freeze
55
+
56
+ # Ruby makes these instance methods private whatever section defines them.
57
+ ALWAYS_PRIVATE = %w[initialize initialize_copy initialize_clone initialize_dup respond_to_missing?].freeze
58
+
59
+ # Ruby's own definers: these honor the section's visibility, as `def` does.
60
+ ATTR_DEFINERS = { attr_reader: [ :reader ], attr_writer: [ :writer ], attr_accessor: %i[reader writer], attr: [ :reader ] }.freeze
61
+ # Active Support's: each defines public class and instance accessors from a string `class_eval`.
62
+ CLASS_ACCESSORS = {
63
+ class_attribute: %i[reader writer], mattr_reader: [ :reader ], cattr_reader: [ :reader ],
64
+ mattr_writer: [ :writer ], cattr_writer: [ :writer ], mattr_accessor: %i[reader writer], cattr_accessor: %i[reader writer]
65
+ }.freeze
66
+ # Sets, because a `when *names` allocates on every call node of every walk.
67
+ BLOCK_FRAME_NAMES = BLOCK_FRAMES.keys.to_set.freeze
68
+ DELEGATOR_NAMES = DELEGATORS.to_set.freeze
69
+ DEFINERS = Set[*ATTR_DEFINERS.keys, :define_method, :alias_method, *CLASS_ACCESSORS.keys].freeze
70
+
29
71
  def on_class_node_enter(node)
30
- @visibility_stack.push(:public)
31
- @inline_visibility_stack.push({})
72
+ open_frame(:body)
32
73
  @owner_stack.push(constant_path_string(node.constant_path))
33
74
  end
34
75
 
35
76
  def on_class_node_leave(node)
36
- @visibility_stack.pop
37
- @inline_visibility_stack.pop
77
+ @frames.pop
38
78
  @owner_stack.pop
39
79
  end
40
80
 
41
81
  def on_module_node_enter(node)
42
- @visibility_stack.push(:public)
43
- @inline_visibility_stack.push({})
82
+ open_frame(:body)
44
83
  @owner_stack.push(constant_path_string(node.constant_path))
45
84
  end
46
85
 
47
86
  def on_module_node_leave(node)
48
- @visibility_stack.pop
49
- @inline_visibility_stack.pop
87
+ @frames.pop
50
88
  @owner_stack.pop
51
89
  end
52
90
 
53
- # Track `class << self` blocks
91
+ # A singleton class body starts public, and a `private` in it stays in it.
54
92
  def on_singleton_class_node_enter(node)
55
- @in_singleton_class = true
56
- @singleton_depth += 1
93
+ open_frame(:singleton)
57
94
  end
58
95
 
59
96
  def on_singleton_class_node_leave(node)
60
- @singleton_depth -= 1
61
- @in_singleton_class = false if @singleton_depth == 0
97
+ @frames.pop
62
98
  end
63
99
 
64
- # Track visibility modifiers: private, protected, public
65
- # Handles both bare form (`private`) and inline form (`private :method_name`)
100
+ # Bare `private` affects every later def in this frame; `private :x`
101
+ # and `private def x` mark the one name, retroactively for :x.
66
102
  def on_call_node_enter(node)
103
+ @scoped_blocks[node.block] = :extension if new_class_body?(node) && !@scoped_blocks.key?(node.block)
67
104
  return unless node.receiver.nil?
68
105
 
69
106
  case node.name
70
107
  when :private, :protected, :public
71
108
  if node.arguments.nil?
72
- # Bare modifier: affects all subsequent defs in this scope
73
- @visibility_stack[-1] = node.name
109
+ @frames.last.visibility = node.name
110
+ else
111
+ mark(node.arguments.arguments, node.name, frame_scope)
112
+ end
113
+ when :private_class_method, :public_class_method
114
+ mark(Array(node.arguments&.arguments), node.name == :private_class_method ? :private : :public, :class)
115
+ when :module_function
116
+ if node.arguments.nil?
117
+ @frames.last.visibility = :module_function
74
118
  else
75
- # Inline form: `private :method_name` - retroactively update
76
- # already-recorded methods and mark for future defs. The def in
77
- # `private def x` is visited after this call, so marking its
78
- # name here is enough.
79
- args = node.arguments.arguments
80
- args.each do |arg|
81
- case arg
82
- when Prism::DefNode
83
- @inline_visibility_stack.last[arg.name.to_s] = node.name
84
- when Prism::SymbolNode
85
- method_name = arg.unescaped
86
- @inline_visibility_stack.last[method_name] = node.name
87
- existing = @results.find { |r| r[:name] == method_name && r[:owner] == @owner_stack }
88
- existing[:visibility] = node.name if existing
89
- end
90
- end
119
+ mark(node.arguments.arguments, :module_function, :instance)
91
120
  end
92
- when :class_methods, :included
93
- @concern_blocks[node.block] = node.name if node.block.is_a?(Prism::BlockNode)
121
+ when BLOCK_FRAME_NAMES
122
+ @scoped_blocks[node.block] = BLOCK_FRAMES[node.name] if node.block.is_a?(Prism::BlockNode)
123
+ when DELEGATOR_NAMES
124
+ record_delegated(node) if @def_depth.zero?
125
+ when DEFINERS
126
+ record_defined(node) if @def_depth.zero? && @frames.last.kind != :extension
94
127
  end
95
128
  end
96
129
 
97
- # A concern block is a visibility scope of its own: a `private` inside
98
- # `class_methods do` does not reach the module's instance methods.
130
+ # `Name = Struct.new do ... end` is Name's body.
131
+ def on_constant_write_node_enter(node)
132
+ open_builder(node, node.name.to_s)
133
+ end
134
+
135
+ def on_constant_write_node_leave(node)
136
+ close_builder(node)
137
+ end
138
+
139
+ def on_constant_path_write_node_enter(node)
140
+ open_builder(node, constant_path_string(node.target))
141
+ end
142
+
143
+ def on_constant_path_write_node_leave(node)
144
+ close_builder(node)
145
+ end
146
+
147
+ # ponytail: a Module.new held in a local is read as the enclosing class's, which is where it is
148
+ # nearly always included; follow the local to its `include` if another use turns up.
149
+ def on_local_variable_write_node_enter(node)
150
+ value = node.value
151
+ @scoped_blocks[value.block] = :body if value.is_a?(Prism::CallNode) && new_class_body?(value) && value.name == :new &&
152
+ value.receiver.name == :Module
153
+ end
154
+
155
+ def on_alias_method_node_enter(node)
156
+ return unless @def_depth.zero? && @frames.last.kind != :extension
157
+
158
+ new_name = literal_string(node.new_name)
159
+ old_name = literal_string(node.old_name)
160
+ record_alias(node, new_name, old_name) if new_name && old_name
161
+ end
162
+
99
163
  def on_block_node_enter(node)
100
- kind = @concern_blocks.delete(node)
164
+ kind = @scoped_blocks.delete(node)
101
165
  return unless kind
102
166
 
103
167
  @open_blocks.push(node)
104
- @scope_stack.push(kind)
105
- @visibility_stack.push(:public)
106
- @inline_visibility_stack.push({})
168
+ open_frame(kind)
107
169
  end
108
170
 
109
171
  def on_block_node_leave(node)
110
172
  return unless @open_blocks.last.equal?(node)
111
173
 
112
174
  @open_blocks.pop
113
- @scope_stack.pop
114
- @visibility_stack.pop
115
- @inline_visibility_stack.pop
175
+ @frames.pop
176
+ end
177
+
178
+ def on_def_node_leave(node)
179
+ @def_depth -= 1
116
180
  end
117
181
 
118
182
  def on_def_node_enter(node)
119
- is_class_method = @in_singleton_class || node.receiver&.is_a?(Prism::SelfNode) || @scope_stack.last == :class_methods
183
+ @def_depth += 1
184
+ @results.def_spans << (node.location.start_offset...node.location.end_offset)
185
+ scope = def_scope(node)
120
186
  method_name = node.name.to_s
187
+ return unless scope
188
+ return if @frames.last.kind == :extension || class_methods_module_own?(node.receiver)
189
+ return if method_name == "initialize" && scope == :instance && !@include_initialize
190
+
191
+ # A bare `private` reaches only defs written without a receiver.
192
+ visibility = ruby_visibility(scope, method_name, node.receiver ? :public : @frames.last.visibility)
193
+ if visibility == :module_function
194
+ record(node, method_name, :instance, :private, prefixed: false)
195
+ @results.last[:module_function] = true
196
+ record(node, method_name, :class, :public, prefixed: true)
197
+ else
198
+ record(node, method_name, scope, visibility, prefixed: !node.receiver.nil?)
199
+ end
200
+ end
201
+
202
+ private
203
+
204
+ # These evaluate their block as a new class or module body.
205
+ CLASS_BUILDERS = { Struct: :new, Class: :new, Module: :new, Data: :define }.freeze
206
+
207
+ def new_class_body?(node)
208
+ receiver = node.receiver
209
+ return false unless node.block.is_a?(Prism::BlockNode)
210
+ return false unless receiver.is_a?(Prism::ConstantReadNode) || (receiver.is_a?(Prism::ConstantPathNode) && receiver.parent.nil?)
211
+
212
+ CLASS_BUILDERS[receiver.name] == node.name
213
+ end
214
+
215
+ def open_builder(node, name)
216
+ value = node.value
217
+ return unless value.is_a?(Prism::CallNode) && new_class_body?(value)
218
+
219
+ @scoped_blocks[value.block] = :body
220
+ @owner_stack.push(name)
221
+ @builders.push(node)
222
+ end
223
+
224
+ def close_builder(node)
225
+ return unless @builders.last.equal?(node)
226
+
227
+ @builders.pop
228
+ @owner_stack.pop
229
+ end
230
+
231
+ # What an includer gains: a `class_methods` def, or a class method written in `included do`.
232
+ def includer_gains?(scope)
233
+ frame = @frames.last
234
+ return true if frame.kind == :class_methods
235
+
236
+ frame = @frames[-2] if frame.kind == :singleton
237
+ frame&.kind == :included && scope == :class
238
+ end
239
+
240
+ # `def self.x` in `class_methods do`, or anything in its `class << self`, is ClassMethods' own:
241
+ # neither the includer nor the concern responds to it.
242
+ def class_methods_module_own?(receiver)
243
+ kinds = @frames.last(2).map(&:kind)
244
+ (kinds.last == :class_methods && !receiver.nil?) || kinds == %i[class_methods singleton]
245
+ end
121
246
 
122
- return if method_name == "initialize" && !is_class_method && !@include_initialize
247
+ def open_frame(kind)
248
+ @frames.push(Frame.new(kind, :public, {}))
249
+ end
250
+
251
+ def ruby_visibility(scope, name, visibility)
252
+ @frames.last.marks.fetch([ scope, name ]) { scope == :instance && ALWAYS_PRIVATE.include?(name) ? :private : visibility }
253
+ end
254
+
255
+ def frame_scope
256
+ CLASS_SCOPE_FRAMES.include?(@frames.last.kind) ? :class : :instance
257
+ end
258
+
259
+ # nil for a singleton def on anything but this class (`def other.x`):
260
+ # it is no method of the class.
261
+ def def_scope(node)
262
+ receiver = node.receiver
263
+ return frame_scope if receiver.nil?
264
+ return :class if receiver.is_a?(Prism::SelfNode)
265
+ return unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
266
+
267
+ owner = @owner_stack.join("::")
268
+ name = constant_path_string(receiver).delete_prefix("::")
269
+ :class if owner == name || owner.end_with?("::#{name}")
270
+ end
271
+
272
+ def mark(args, visibility, scope)
273
+ marks = @frames.last.marks
274
+ args.each do |arg|
275
+ case arg
276
+ when Prism::DefNode
277
+ marks[[ def_scope(arg), arg.name.to_s ]] = visibility
278
+ when Prism::SymbolNode
279
+ name = arg.unescaped
280
+ marks[[ scope, name ]] = visibility
281
+ existing = @results.reverse_each.find { |r| r[:name] == name && r[:scope] == scope && r[:owner] == @owner_stack }
282
+ apply_visibility(existing, visibility) if existing
283
+ when Prism::CallNode
284
+ # Since Ruby 3.0 these return the names they define, so `private attr_reader :x` works.
285
+ defined_names(arg).each { |name| marks[[ scope, name ]] = visibility } unless visibility == :module_function
286
+ end
287
+ end
288
+ end
123
289
 
124
- # Inline visibility (`private :foo`) takes precedence over positional
125
- visibility = @inline_visibility_stack.last[method_name] || @visibility_stack.last
290
+ def defined_names(call)
291
+ return [] unless call.receiver.nil?
126
292
 
127
- params = extract_params(node)
293
+ names = Array(call.arguments&.arguments).filter_map { |a| literal_string(a) }
294
+ case call.name
295
+ when :define_method, :alias_method then names.first(1)
296
+ when *ATTR_DEFINERS.keys
297
+ kinds = ATTR_DEFINERS[call.name]
298
+ names.flat_map { |name| [ (name if kinds.include?(:reader)), ("#{name}=" if kinds.include?(:writer)) ].compact }
299
+ else []
300
+ end
301
+ end
302
+
303
+ def apply_visibility(entry, visibility)
304
+ return entry[:visibility] = visibility unless visibility == :module_function
128
305
 
129
- @results << {
306
+ entry[:visibility] = :private
307
+ entry[:module_function] = true
308
+ @results << entry.merge(scope: :class, visibility: :public, signature: "self.#{entry[:signature]}").except(:module_function)
309
+ end
310
+
311
+ def record(node, method_name, scope, visibility, prefixed:)
312
+ slices = parameter_slices(node.parameters)
313
+ entry = {
130
314
  name: method_name,
131
- scope: is_class_method ? :class : :instance,
315
+ scope: scope,
132
316
  visibility: visibility,
133
- params: params,
317
+ params: extract_params(node),
134
318
  # Enclosing class/module names, outermost first. A caller that
135
319
  # wants one class's own methods needs this: a helper class nested
136
320
  # inside a service is a separate owner, not part of its interface.
137
321
  owner: @owner_stack.dup,
138
322
  # Sliced off the node so defaults read as written (`options = {}`);
139
323
  # `params` records names only.
140
- signature: signature_source(node, is_class_method),
324
+ signature: signature_text("#{'self.' if prefixed}#{node.name}", slices),
141
325
  location: node.location.start_line,
142
326
  end_location: node.location.end_line,
327
+ # Offsets, unlike lines, tell a call that shares a line with a def from one inside it.
328
+ offset: node.location.start_offset,
329
+ end_offset: node.location.end_offset,
143
330
  confidence: RailsAiContext::Confidence::VERIFIED
144
331
  }
332
+ # Outside `included do`, a `def self.x` or `class << self` method stays on the module.
333
+ entry[:class_methods_block] = true if includer_gains?(scope)
334
+ @param_slices[entry] = slices
335
+ @results << entry
145
336
  end
146
337
 
147
- private
338
+ def record_defined(node)
339
+ names = Array(node.arguments&.arguments).filter_map { |a| literal_string(a) }
340
+ case node.name
341
+ when :define_method
342
+ record_name(node, names.first, frame_scope, @frames.last.visibility) if names.first
343
+ when :alias_method
344
+ record_alias(node, *names.first(2)) if names.size >= 2
345
+ when *ATTR_DEFINERS.keys
346
+ names.each { |name| record_accessors(node, name, ATTR_DEFINERS[node.name], frame_scope, @frames.last.visibility) }
347
+ else
348
+ record_class_accessors(node, names)
349
+ end
350
+ end
351
+
352
+ def record_class_accessors(node, names)
353
+ kinds = CLASS_ACCESSORS[node.name]
354
+ options = extract_keyword_options(node)
355
+ off = ->(key) { options[key] == false || options[:instance_accessor] == false }
356
+ instance = kinds.reject { |kind| off.call(kind == :reader ? :instance_reader : :instance_writer) }
357
+ predicate = node.name == :class_attribute && options[:instance_predicate] != false
358
+ names.each do |name|
359
+ record_accessors(node, name, kinds, :class, :public)
360
+ record_name(node, "#{name}?", :class, :public) if predicate
361
+ record_accessors(node, name, instance, :instance, :public)
362
+ record_name(node, "#{name}?", :instance, :public) if predicate && instance.include?(:reader)
363
+ end
364
+ end
365
+
366
+ def record_accessors(node, name, kinds, scope, visibility)
367
+ record_name(node, name, scope, visibility) if kinds.include?(:reader)
368
+ record_name(node, "#{name}=", scope, visibility, signature: "#{name}=(value)") if kinds.include?(:writer)
369
+ end
370
+
371
+ # An alias carries its original's visibility and parameters, whatever section it sits in.
372
+ def record_alias(node, new_name, old_name)
373
+ scope = frame_scope
374
+ original = @results.reverse_each.find { |r| r[:name] == old_name && r[:scope] == scope && r[:owner] == @owner_stack }
375
+ slices = original ? @param_slices.fetch(original, []) : []
376
+ record_name(node, new_name, scope, original ? original[:visibility] : :public,
377
+ signature: signature_text(new_name, slices), params: original ? original[:params].dup : [], slices: slices)
378
+ end
379
+
380
+ def record_name(node, name, scope, visibility, signature: name, params: [], slices: [])
381
+ return if class_methods_module_own?(nil)
382
+
383
+ visibility = :public if visibility == :module_function
384
+ entry = {
385
+ name: name,
386
+ scope: scope,
387
+ visibility: ruby_visibility(scope, name, visibility),
388
+ params: params,
389
+ owner: @owner_stack.dup,
390
+ signature: signature,
391
+ # No end: these have no body for a call to sit inside.
392
+ location: node.location.start_line,
393
+ confidence: node.is_a?(Prism::CallNode) ? confidence_for(node) : RailsAiContext::Confidence::VERIFIED
394
+ }
395
+ entry[:class_methods_block] = true if includer_gains?(scope)
396
+ @param_slices[entry] = slices
397
+ @results << entry
398
+ end
399
+
400
+ def record_delegated(node)
401
+ return if @frames.last.kind == :extension
402
+
403
+ args = node.arguments&.arguments || []
404
+ positional = args.reject { |a| a.is_a?(Prism::KeywordHashNode) }
405
+ options = extract_keyword_options(node)
406
+ visibility = :public
407
+ names = case node.name
408
+ when :def_delegators, :def_instance_delegators
409
+ positional.drop(1).filter_map { |a| literal_string(a) } - %w[__send__ __id__]
410
+ when :def_delegator, :def_instance_delegator
411
+ Array(literal_string(positional[2] || positional[1]))
412
+ else
413
+ if options.key?(:to)
414
+ visibility = :private if options[:private] == true
415
+ prefix = delegation_prefix(options)
416
+ return unless prefix
417
+
418
+ positional.filter_map { |a| literal_string(a) }.map { |name| "#{prefix}#{name}" }
419
+ else
420
+ # Forwardable's `delegate [:a, :b] => :@x`.
421
+ args.grep(Prism::KeywordHashNode).flat_map(&:elements).grep(Prism::AssocNode).flat_map { |assoc| literal_strings(assoc.key) }
422
+ end
423
+ end
424
+ names.each { |name| record_name(node, name, frame_scope, visibility) }
425
+ end
426
+
427
+ def delegation_prefix(options)
428
+ case options[:prefix]
429
+ # Rails raises on `prefix: true` with an ivar target, so nothing is defined.
430
+ when true then options[:to].to_s.start_with?("@") ? nil : "#{options[:to]}_"
431
+ when Symbol, String then "#{options[:prefix]}_"
432
+ else ""
433
+ end
434
+ end
148
435
 
149
436
  # `class << self` members carry no receiver of their own, so they read
150
437
  # as the bare name, which is how they are written.
151
- def signature_source(node, is_class_method)
152
- prefix = (is_class_method && node.receiver) ? "self." : ""
153
- params = parameter_slices(node.parameters)
154
- return "#{prefix}#{node.name}" if params.empty?
155
-
156
- "#{prefix}#{node.name}(#{params.join(', ')})"
438
+ def signature_text(name, slices)
439
+ slices.empty? ? name : "#{name}(#{slices.join(', ')})"
157
440
  end
158
441
 
159
442
  # Each parameter is sliced on its own rather than taking the whole list
@@ -91,7 +91,7 @@ module RailsAiContext
91
91
  }
92
92
 
93
93
  case node.name
94
- when :add_column
94
+ when :add_column, :remove_column
95
95
  result[:column_type] = literal_string(args[2])
96
96
  when :rename_column
97
97
  result[:new_name] = literal_string(args[2])
@@ -3,8 +3,8 @@
3
3
  module RailsAiContext
4
4
  module Introspectors
5
5
  module Listeners
6
- # What a replay needs beyond the DSL listeners: down-only ranges (a revert
7
- # block is one), a block's `t.` statements, and four top-level statements.
6
+ # What a replay needs beyond the DSL listeners: down-only and revert ranges,
7
+ # a block's `t.` statements, and the top-level statements the DSL listeners leave out.
8
8
  class MigrationReplayListener < BaseListener
9
9
  include SchemaDslListener::TableBlock
10
10
 
@@ -19,16 +19,17 @@ module RailsAiContext
19
19
  remove: :remove_columns, remove_references: :remove_reference, remove_belongs_to: :remove_reference,
20
20
  remove_index: :remove_index, rename: :rename_column, remove_timestamps: :remove_columns,
21
21
  change: :change_column, change_default: :change_column_default, change_null: :change_column_null,
22
- timestamps: :add_timestamps
22
+ timestamps: :add_timestamps, check_constraint: :add_check_constraint, remove_check_constraint: :remove_check_constraint
23
23
  }.freeze
24
24
 
25
25
  def on_call_node_enter(node)
26
26
  note_block(node)
27
27
  args = node.arguments&.arguments || []
28
28
  if %i[down revert].include?(node.name) && node.block
29
- @results << { kind: :down, range: node.location.start_line..node.location.end_line }
30
- elsif node.name == :column && block_column?(node.receiver)
31
- @results << column_definition(node, args)
29
+ @results << { kind: node.name, range: node.location.start_line..node.location.end_line }
30
+ elsif node.name == :revert && node.receiver.nil? && args.any?
31
+ # `revert SomeMigration` runs another file's statements inverted, which this file does not hold.
32
+ @results << { kind: :not_replayed, location: node.location.start_line }
32
33
  elsif TABLE_OPS[node.name] == :remove_reference && block_column?(node.receiver)
33
34
  # Table#remove_references drops each name it is given.
34
35
  args.reject { |arg| arg.is_a?(Prism::KeywordHashNode) }.each do |arg|
@@ -40,29 +41,43 @@ module RailsAiContext
40
41
  tables = args.reject { |arg| arg.is_a?(Prism::KeywordHashNode) }.first(2).map { |arg| literal_string(arg) }
41
42
  @results << { action: :create_join_table, tables: tables, options: extract_keyword_options(node),
42
43
  location: node.location.start_line }
43
- elsif %i[remove_columns add_timestamps].include?(node.name) && node.receiver.nil?
44
+ elsif %i[remove_columns add_timestamps rename_index add_check_constraint remove_check_constraint].include?(node.name) && node.receiver.nil?
44
45
  @results << top_level(node, args)
45
46
  elsif node.name == :execute && node.receiver.nil?
46
- dropped_tables(args.first).each do |table|
47
- @results << { action: :drop_table, table: table, options: {}, location: node.location.start_line }
47
+ sql_node = args.first
48
+ sql_node = sql_node.receiver if sql_node.is_a?(Prism::CallNode) && sql_node.name == :squish && sql_node.arguments.nil?
49
+ statements = sql_statements(sql_node)
50
+ # SQL built at run time that creates or drops a table is counted as not replayed.
51
+ if statements.nil?
52
+ literal = sql_node.respond_to?(:parts) ? sql_node.parts.grep(Prism::StringNode).map(&:unescaped).join : ""
53
+ @results << { kind: :not_replayed, location: node.location.start_line } if literal.match?(/\b(?:CREATE|DROP)\s+TABLE\b/i)
54
+ return
55
+ end
56
+ statements.each do |sql|
57
+ if (match = DROP_TABLE.match(sql))
58
+ match[1].split(",").each do |name|
59
+ @results << { action: :drop_table, table: name.strip.delete('"`').split(".").last, options: {}, location: node.location.start_line }
60
+ end
61
+ elsif sql.match?(CREATE_TABLE)
62
+ @results << { action: :create_table_sql, sql: sql, location: node.location.start_line }
63
+ end
48
64
  end
49
65
  end
50
66
  end
51
67
 
52
68
  DROP_TABLE = /\A\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?((?:[\w."`]+\s*,\s*)*[\w."`]+)(?:\s+(?:CASCADE|RESTRICT))?\s*\z/i
69
+ CREATE_TABLE = /\A\s*CREATE\s+TABLE\s/i
53
70
 
54
71
  private
55
72
 
56
- # The tables a literal SQL string drops, one statement at a time; any
57
- # other SQL, or a string built at run time, drops nothing here.
58
- def dropped_tables(node)
59
- node = node.receiver if node.is_a?(Prism::CallNode) && node.name == :squish && node.arguments.nil?
60
- return [] unless node.is_a?(Prism::StringNode)
61
-
62
- node.unescaped.split(";").flat_map do |sql|
63
- match = DROP_TABLE.match(sql) or next []
64
- match[1].split(",").map { |name| name.strip.delete('"`').split(".").last }
73
+ # A literal SQL string's statements, which drop or create tables here; nil for a string built at run time.
74
+ def sql_statements(node)
75
+ sql = case node
76
+ when Prism::StringNode then node.unescaped
77
+ # A squiggly heredoc over several lines parses as one string per line.
78
+ when Prism::InterpolatedStringNode then adjacent_literals(node)
65
79
  end
80
+ sql.split(";") unless sql.nil? || sql == RailsAiContext::Confidence::INFERRED
66
81
  end
67
82
 
68
83
  # A statement naming a table it cannot read is counted, never given another table.
@@ -83,24 +98,18 @@ module RailsAiContext
83
98
  # remove_index takes one column or an array of them.
84
99
  when :remove_index then result[:columns] = literal_strings(positional.first)
85
100
  when :rename_column then result.merge!(column: names[0], new_name: names[1])
101
+ when :rename_index then result.merge!(old_name: names[0], new_name: names[1])
86
102
  when :change_column then result.merge!(column: names[0], column_type: names[1])
87
103
  when :change_column_null then result.merge!(column: names[0], null: boolean_value(positional[1]))
88
104
  when :change_column_default
89
105
  result[:column] = names[0]
90
106
  result[:new_default] = extract_value(positional[1]) if positional[1]
91
107
  when :add_timestamps then result.merge!(default_source: default_source(node), default_proc: proc_default?(node))
108
+ when :add_check_constraint, :remove_check_constraint then result[:expression] = names[0]
92
109
  end
93
110
  result
94
111
  end
95
112
 
96
- # t.column is TableDefinition#column: a column definition like t.string.
97
- def column_definition(node, args)
98
- positional = args.reject { |arg| arg.is_a?(Prism::KeywordHashNode) }
99
- { type: :column, name: literal_string(positional[0]), column_type: literal_string(positional[1]),
100
- options: extract_keyword_options(node), default_source: default_source(node), default_proc: proc_default?(node),
101
- location: node.location.start_line }
102
- end
103
-
104
113
  # The block parameter a create_table body names `t`, whether it reads
105
114
  # as a local variable or as a bare call.
106
115
  def block_column?(receiver)