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
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ module Listeners
6
+ # Calls on an HTTP client constant with a literal URL: `Faraday.get("https://x")`,
7
+ # `Net::HTTP.get(URI("https://x"))`, `Faraday.new(url: "https://x")`, `URI.open("https://x")`,
8
+ # and `Net::HTTP.start("api.x.com", 443)`, whose first argument is a host.
9
+ class HttpClientCallListener < BaseListener
10
+ CLIENTS = %w[Faraday Net::HTTP HTTParty RestClient HTTP Excon Typhoeus].freeze
11
+
12
+ def on_call_node_enter(node)
13
+ client = client_name(node) or return
14
+
15
+ first = node.arguments&.arguments&.first
16
+ if client == "Net::HTTP" && %i[start new].include?(node.name)
17
+ host = literal_string(first)
18
+ @results << { client: client, host: host, line: node.location.start_line } if host
19
+ elsif (url = url_literal(first) || url_literal(extract_keyword_nodes(node)[:url]))
20
+ @results << { client: client, url: url, line: node.location.start_line }
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def client_name(node)
27
+ receiver = node.receiver
28
+ return nil unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
29
+
30
+ name = constant_path_string(receiver)
31
+ return "URI.open" if name == "URI" && node.name == :open
32
+
33
+ name if CLIENTS.include?(name)
34
+ end
35
+
36
+ # A string literal, or one wrapped in `URI(...)` or `URI.parse(...)`.
37
+ def url_literal(node)
38
+ return node.unescaped if node.is_a?(Prism::StringNode)
39
+ return nil unless node.is_a?(Prism::CallNode)
40
+
41
+ receiver = node.receiver
42
+ wrapped = (receiver.nil? && node.name == :URI) ||
43
+ (node.name == :parse && receiver.is_a?(Prism::ConstantReadNode) && receiver.name == :URI)
44
+ inner = node.arguments&.arguments&.first
45
+ inner.unescaped if wrapped && inner.is_a?(Prism::StringNode)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ module Listeners
6
+ # Locale files an app adds to I18n's load path, literal paths and globs only:
7
+ #
8
+ # config.i18n.load_path += Dir[Rails.root.join("my/locales/*.{rb,yml}")] → "my/locales/*.{rb,yml}"
9
+ # I18n.load_path << "#{Rails.root}/lib/locales/de.yml" → "lib/locales/de.yml"
10
+ # config.i18n.load_path = Dir[Rails.root.join("x/*.yml")] → "x/*.yml"
11
+ #
12
+ # The path forms are LiteralPaths', plus the Dir[] / Dir.glob wrapper.
13
+ class I18nLoadPathListener < BaseListener
14
+ include LiteralPaths
15
+
16
+ def on_call_node_enter(node)
17
+ if node.name == :load_path= && i18n?(node.receiver)
18
+ collect_paths(node.arguments&.arguments&.first)
19
+ elsif APPENDING.include?(node.name) && load_path?(node.receiver)
20
+ Array(node.arguments&.arguments).each { |argument| collect_paths(argument) }
21
+ end
22
+ end
23
+
24
+ def on_call_operator_write_node_enter(node)
25
+ return unless node.binary_operator == :+ && node.read_name == :load_path && i18n?(node.receiver)
26
+
27
+ collect_paths(node.value)
28
+ end
29
+
30
+ private
31
+
32
+ def load_path?(node)
33
+ node.is_a?(Prism::CallNode) && node.name == :load_path && i18n?(node.receiver)
34
+ end
35
+
36
+ def i18n?(node)
37
+ case node
38
+ when Prism::CallNode then node.name == :i18n
39
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then node.slice.delete_prefix("::") == "I18n"
40
+ else false
41
+ end
42
+ end
43
+
44
+ def collect_call_path(node)
45
+ if %i[[] glob].include?(node.name) && node.receiver&.slice&.delete_prefix("::") == "Dir"
46
+ return collect_paths(node.arguments&.arguments&.first)
47
+ end
48
+
49
+ super
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module RailsAiContext
6
+ module Introspectors
7
+ module Listeners
8
+ # The literal path forms an app writes a setting with: a string, `%W(#{config.root}/x)`,
9
+ # `Rails.root.join("x")`. Each one read lands in `push_path`; a path built at run time stays unread.
10
+ # `collect_paths` returns whether it read the whole node, so a caller can tell a partly read write.
11
+ module LiteralPaths
12
+ APPENDING = %i[<< push append concat].to_set.freeze
13
+
14
+ private
15
+
16
+ def paths_receiver?(node)
17
+ node.is_a?(Prism::CallNode) && node.name == :paths
18
+ end
19
+
20
+ def collect_paths(node)
21
+ case node
22
+ when Prism::ArrayNode then node.elements.map { |element| collect_paths(element) }.all?
23
+ when Prism::StringNode then push_path(node.unescaped)
24
+ when Prism::InterpolatedStringNode then collect_interpolated_path(node)
25
+ when Prism::CallNode then collect_call_path(node)
26
+ end
27
+ end
28
+
29
+ # `"#{config.root}/lib_static"`, and only that: a gem's root names a path outside
30
+ # the app, and a later interpolation leaves the path unknown.
31
+ def collect_interpolated_path(node)
32
+ return unless app_root?(node.parts.first) && node.parts.drop(1).all?(Prism::StringNode)
33
+
34
+ push_path(node.parts.drop(1).map(&:content).join)
35
+ end
36
+
37
+ # `Rails.root.join("lib_static")` and its `.to_s`, whose arguments are
38
+ # the path relative to the app root.
39
+ def collect_call_path(node)
40
+ return collect_file_path(node) if @file && file_constant?(node.receiver)
41
+ return collect_paths(node.receiver) unless node.name == :join
42
+
43
+ segments = app_root_join(node)
44
+ push_path(File.join(*segments)) if segments
45
+ end
46
+
47
+ # `File.expand_path("x", __dir__)` and `File.join(__dir__, "x")`, read when the
48
+ # listener is given `@file`, the walked file's path relative to the app root.
49
+ def collect_file_path(node)
50
+ arguments = Array(node.arguments&.arguments)
51
+ path = case node.name
52
+ when :expand_path then file_relative(arguments.first, arguments[1]) if arguments.size == 2
53
+ when :join then file_relative_join(arguments)
54
+ end
55
+ push_path(path) if path
56
+ end
57
+
58
+ def file_relative(relative, base)
59
+ return unless relative.is_a?(Prism::StringNode)
60
+
61
+ anchor = file_anchor(base) or return
62
+ Pathname.new(File.join(anchor, relative.unescaped)).cleanpath.to_s
63
+ end
64
+
65
+ def file_relative_join(arguments)
66
+ anchor = file_anchor(arguments.first) or return
67
+ rest = arguments.drop(1)
68
+ return unless rest.any? && rest.all?(Prism::StringNode)
69
+
70
+ Pathname.new(File.join(anchor, *rest.map(&:unescaped))).cleanpath.to_s
71
+ end
72
+
73
+ # `File.expand_path("x", __FILE__)` resolves against the file name itself, as Ruby does.
74
+ def file_anchor(node)
75
+ case node
76
+ when Prism::SourceFileNode then @file
77
+ when Prism::CallNode then File.dirname(@file) if dir_call?(node) || file_dirname_call?(node)
78
+ end
79
+ end
80
+
81
+ # `File.dirname(__FILE__)`, the older spelling of `__dir__`.
82
+ def file_dirname_call?(node)
83
+ arguments = Array(node.arguments&.arguments)
84
+ node.name == :dirname && file_constant?(node.receiver) && arguments.size == 1 && arguments.first.is_a?(Prism::SourceFileNode)
85
+ end
86
+
87
+ # The segments of `Rails.root.join("a", "b")`, nil unless every one is a literal.
88
+ def app_root_join(node)
89
+ return nil unless node.is_a?(Prism::CallNode) && node.name == :join && app_root?(node.receiver)
90
+
91
+ segments = Array(node.arguments&.arguments)
92
+ segments.map(&:unescaped) if segments.any? && segments.all?(Prism::StringNode)
93
+ end
94
+
95
+ # `root`, `config.root`, `Rails.root` or `::Rails.root`.
96
+ def app_root?(node)
97
+ node = node.statements if node.is_a?(Prism::EmbeddedStatementsNode)
98
+ node = node.body.first if node.is_a?(Prism::StatementsNode)
99
+ return false unless node.is_a?(Prism::CallNode) && node.name == :root && node.arguments.nil? && node.block.nil?
100
+
101
+ receiver = node.receiver
102
+ case receiver
103
+ when nil then true
104
+ when Prism::CallNode then receiver.name == :config && receiver.receiver.nil? && receiver.arguments.nil?
105
+ when Prism::LocalVariableReadNode then receiver.name == :config
106
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then constant_path_string(receiver) == "Rails"
107
+ else false
108
+ end
109
+ end
110
+
111
+ def push_path(value)
112
+ cleaned = value.to_s.strip.delete_prefix("/")
113
+ record_path(cleaned) unless cleaned.empty?
114
+ true
115
+ end
116
+
117
+ def record_path(path)
118
+ @results << path
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -9,6 +9,8 @@ module RailsAiContext
9
9
  # broadcasts, generates_token_for, attribute, etc.
10
10
  class MacrosListener < BaseListener
11
11
  include WithOptionsScope
12
+ include OwnerScope
13
+ include BranchConditions
12
14
 
13
15
  SIMPLE_MACROS = %i[
14
16
  has_secure_password
@@ -16,24 +18,109 @@ module RailsAiContext
16
18
 
17
19
  ATTRIBUTE_MACROS = %i[
18
20
  encrypts normalizes has_one_attached has_many_attached
19
- has_rich_text generates_token_for serialize store store_accessor
21
+ has_rich_text generates_token_for serialize attr_readonly query_constraints
20
22
  ].to_set.freeze
21
23
 
24
+ # Class settings each change how the model reads, writes or loads; kept as written.
25
+ SETTINGS = %i[
26
+ inheritance_column= store_full_sti_class= strict_loading_by_default=
27
+ implicit_order_column= locking_column=
28
+ ].to_set.freeze
29
+
30
+ STORE_MACROS = %i[store store_accessor].to_set.freeze
31
+
22
32
  BROADCAST_MACROS = %i[
23
33
  broadcasts broadcasts_to broadcasts_refreshes_to
24
34
  ].to_set.freeze
25
35
 
36
+ # Model gems' class macros, each listed as written; aasm's block is read in full.
37
+ GEM_MACROS = %i[
38
+ has_paper_trail audited acts_as_paranoid friendly_id
39
+ mount_uploader mount_uploaders monetize
40
+ pg_search_scope multisearchable searchkick
41
+ acts_as_list acts_as_tenant multi_tenant acts_as_taggable acts_as_taggable_on
42
+ has_ancestry acts_as_nested_set has_closure_tree acts_as_tree
43
+ state_machine workflow
44
+ ].to_set.freeze
45
+
46
+ def initialize
47
+ super
48
+ @def_depth = 0
49
+ @eval_depths = []
50
+ end
51
+
52
+ def on_def_node_enter(_node)
53
+ @def_depth += 1
54
+ end
55
+
56
+ def on_def_node_leave(_node)
57
+ @def_depth -= 1
58
+ end
59
+
60
+ # A method body runs only when called, so only a call sent to a mixin hook's
61
+ # includer counts there. Each record names the class it is written in.
26
62
  def on_call_node_enter(node)
63
+ return if @def_depth.positive? && !(node.receiver && in_scope?(node))
64
+
65
+ owned { record_call(node) }
66
+ # `base.class_eval do` in a hook runs its block on the includer, as a class body.
67
+ return unless @def_depth.positive? && node.block && WithOptionsScope::EVALS.include?(node.name)
68
+
69
+ @eval_depths.push([ node, @def_depth ])
70
+ @def_depth = 0
71
+ end
72
+
73
+ def on_call_node_leave(node)
74
+ @def_depth = @eval_depths.pop.last if @eval_depths.last&.first.equal?(node)
75
+ @aasm = nil if @aasm && @aasm[:node].equal?(node)
76
+ @event = nil if @event && @event[:node].equal?(node)
77
+ end
78
+
79
+ # self.ignored_columns += [...] and -= [...]
80
+ def on_call_operator_write_node_enter(node)
81
+ return unless @def_depth.zero? && node.read_name == :ignored_columns && node.receiver.is_a?(Prism::SelfNode)
82
+
83
+ op = { :+ => :add, :- => :remove }[node.binary_operator]
84
+ owned { record_ignored_columns(node, op) } if op
85
+ end
86
+
87
+ private
88
+
89
+ def owned
90
+ count = @results.size
91
+ yield
92
+ @results.drop(count).each { |result| result[:owner] = @owner_stack.dup }
93
+ end
94
+
95
+ def record_call(node)
96
+ return record_ignored_columns(node, :assign) if node.name == :ignored_columns= && node.receiver.is_a?(Prism::SelfNode)
97
+ return record_setting(node) if SETTINGS.include?(node.name) && node.receiver.is_a?(Prism::SelfNode)
27
98
  return unless in_scope?(node)
99
+ return read_aasm(node) if @aasm
28
100
 
29
- if SIMPLE_MACROS.include?(node.name)
101
+ if node.name == :aasm
102
+ open_aasm(node) if node.block || node.arguments
103
+ elsif GEM_MACROS.include?(node.name)
104
+ record_gem_macro(node)
105
+ elsif node.name == :connects_to
106
+ # Under a condition the app may never call it, so no table is routed to its database.
107
+ condition = current_condition
108
+ @results << { macro: :connects_to, text: one_line_source(node), condition: condition,
109
+ writing: (writing_database(node) unless condition),
110
+ location: node.location.start_line, confidence: confidence_for(node) }.compact
111
+ elsif SIMPLE_MACROS.include?(node.name)
112
+ # Rails defaults the attribute to :password.
30
113
  @results << {
31
114
  macro: node.name,
115
+ attribute: (extract_symbol_args(node).first || :password).to_s,
116
+ written: written_options(node),
32
117
  location: node.location.start_line,
33
118
  confidence: confidence_for(node)
34
119
  }
35
120
  elsif ATTRIBUTE_MACROS.include?(node.name)
36
121
  extract_attribute_macro(node)
122
+ elsif STORE_MACROS.include?(node.name)
123
+ extract_store(node)
37
124
  elsif BROADCAST_MACROS.include?(node.name)
38
125
  extract_broadcast_macro(node)
39
126
  elsif node.name == :delegate
@@ -42,10 +129,93 @@ module RailsAiContext
42
129
  extract_delegate_missing_to(node)
43
130
  elsif node.name == :attribute
44
131
  extract_attribute_api(node)
132
+ elsif node.name == :alias_attribute
133
+ extract_alias_attribute(node)
134
+ elsif node.name == :has_secure_token
135
+ extract_secure_token(node)
136
+ elsif node.name == :accepts_nested_attributes_for
137
+ extract_nested_attributes(node)
45
138
  end
46
139
  end
47
140
 
48
- private
141
+ # Every keyword option as the file writes it: a lambda, `2.days` or a nested hash
142
+ # reads the same on every Ruby, where a value's inspect does not.
143
+ def written_options(node)
144
+ keyword_hash(node) { |value| one_line_source(value) }
145
+ end
146
+
147
+ def record_setting(node)
148
+ value = node.arguments&.arguments&.first or return
149
+ @results << { macro: :model_setting, setting: node.name.to_s.delete_suffix("="), value: value.slice,
150
+ literal: setting_literal(value), location: node.location.start_line, confidence: confidence_for(node) }
151
+ end
152
+
153
+ # A symbol or string names what it spells, nil is nil; anything else is not known without running it.
154
+ def setting_literal(value)
155
+ case value
156
+ when Prism::SymbolNode, Prism::StringNode then value.unescaped
157
+ when Prism::NilNode then nil
158
+ else RailsAiContext::Confidence::INFERRED
159
+ end
160
+ end
161
+
162
+ def record_gem_macro(node)
163
+ text = one_line_source(node, upto: node.block&.location&.start_offset)
164
+ text = text.gsub(/\s+/, " ").strip
165
+ adds = monetized_names(node) if node.name == :monetize
166
+ @results << { macro: :gem_macro, name: node.name, text: text, adds: adds.presence,
167
+ location: node.location.start_line, confidence: confidence_for(node) }.compact
168
+ end
169
+
170
+ # money-rails names the attribute `as:`, or the column minus its `_cents` postfix.
171
+ def monetized_names(node)
172
+ as = extract_keyword_options(node)[:as]
173
+ return [ as.to_s ] if as.is_a?(Symbol) || as.is_a?(String)
174
+
175
+ extract_symbol_args(node).map(&:to_s).filter_map { |column| column.delete_suffix("_cents") if column.end_with?("_cents") }
176
+ end
177
+
178
+ # A named machine's column defaults to its name (AASM::Base#default_column).
179
+ def open_aasm(node)
180
+ name = extract_symbol_args(node).first
181
+ column = extract_keyword_options(node)[:column] || (name && name != :default ? name : "aasm_state")
182
+ entry = { macro: :aasm, column: column.to_s, initial: nil, states: [], events: [],
183
+ location: node.location.start_line, confidence: confidence_for(node) }
184
+ @results << entry
185
+ @aasm = { node: node, entry: entry } if node.block.is_a?(Prism::BlockNode)
186
+ end
187
+
188
+ def read_aasm(node)
189
+ entry = @aasm[:entry]
190
+ case node.name
191
+ when :state
192
+ names = extract_symbol_args(node).map(&:to_s)
193
+ entry[:states].concat(names)
194
+ # The first state is the initial one until a state says `initial: true`.
195
+ entry[:initial] = names.first if names.any? && (entry[:initial].nil? || extract_keyword_options(node)[:initial] == true)
196
+ when :event
197
+ name = extract_symbol_args(node).first or return
198
+ event = { name: name.to_s, transitions: [] }
199
+ entry[:events] << event
200
+ @event = { node: node, event: event } if node.block
201
+ when :transitions
202
+ options = extract_keyword_nodes(node)
203
+ from = options[:from] ? literal_strings(options[:from]) : []
204
+ @event[:event][:transitions] << { from: from, to: options[:to] && literal_string(options[:to]) } if @event
205
+ end
206
+ end
207
+
208
+ def record_ignored_columns(node, op)
209
+ value = node.is_a?(Prism::CallNode) ? node.arguments&.arguments&.first : node.value
210
+ columns = value.is_a?(Prism::ArrayNode) && value.elements.all? { |e| literal_string(e) } ? literal_strings(value) : nil
211
+ @results << {
212
+ macro: :ignored_columns,
213
+ op: op,
214
+ columns: columns,
215
+ location: node.location.start_line,
216
+ confidence: columns ? RailsAiContext::Confidence::VERIFIED : RailsAiContext::Confidence::INFERRED
217
+ }
218
+ end
49
219
 
50
220
  def extract_attribute_macro(node)
51
221
  attrs = extract_symbol_args(node)
@@ -56,12 +226,61 @@ module RailsAiContext
56
226
  macro: node.name,
57
227
  attribute: attr_name.to_s,
58
228
  options: options,
229
+ written: written_options(node),
59
230
  location: node.location.start_line,
60
231
  confidence: confidence_for(node)
61
232
  }
62
233
  end
63
234
  end
64
235
 
236
+ # store :col, accessors: [...] and store_accessor :col, *keys both name
237
+ # the column first; the keys follow it, positionally or as accessors:.
238
+ def extract_store(node)
239
+ args = node.arguments&.arguments || []
240
+ column = args.first && literal_string(args.first)
241
+ return unless column
242
+
243
+ keys = args.drop(1).reject { |a| a.is_a?(Prism::KeywordHashNode) }.flat_map { |a| literal_strings(a) }
244
+ keys_node = extract_keyword_nodes(node)[:accessors]
245
+ keys += literal_strings(keys_node) if keys_node
246
+
247
+ @results << {
248
+ macro: node.name,
249
+ attribute: column,
250
+ keys: keys,
251
+ options: extract_keyword_options(node).slice(:prefix, :suffix),
252
+ location: node.location.start_line,
253
+ confidence: confidence_for(node)
254
+ }
255
+ end
256
+
257
+ # Rails defaults the attribute to :token.
258
+ def extract_secure_token(node)
259
+ args = node.arguments&.arguments || []
260
+ first = args.first
261
+ return if first && !first.is_a?(Prism::KeywordHashNode) && literal_string(first).nil?
262
+
263
+ @results << {
264
+ macro: :has_secure_token,
265
+ attribute: first && literal_string(first) || "token",
266
+ location: node.location.start_line,
267
+ confidence: confidence_for(node)
268
+ }
269
+ end
270
+
271
+ def extract_nested_attributes(node)
272
+ names = extract_symbol_args(node).map(&:to_s)
273
+ return if names.empty?
274
+
275
+ @results << {
276
+ macro: :accepts_nested_attributes_for,
277
+ names: names,
278
+ options: extract_keyword_sources(node),
279
+ location: node.location.start_line,
280
+ confidence: confidence_for(node)
281
+ }
282
+ end
283
+
65
284
  def extract_broadcast_macro(node)
66
285
  target = extract_symbol_args(node).first
67
286
  @results << {
@@ -97,6 +316,15 @@ module RailsAiContext
97
316
  }
98
317
  end
99
318
 
319
+ # The database `connects_to database: { writing: :analytics }` writes to, which names its schema dump.
320
+ def writing_database(node)
321
+ roles = extract_keyword_nodes(node)[:database]
322
+ return unless roles.is_a?(Prism::HashNode)
323
+
324
+ pair = roles.elements.find { |element| element.is_a?(Prism::AssocNode) && literal_string(element.key) == "writing" }
325
+ pair && literal_string(pair.value)
326
+ end
327
+
100
328
  def extract_attribute_api(node)
101
329
  args = node.arguments&.arguments || []
102
330
  return if args.empty?
@@ -107,9 +335,12 @@ module RailsAiContext
107
335
  type_arg = args[1]
108
336
  type = case type_arg
109
337
  when Prism::SymbolNode then type_arg.unescaped
338
+ when nil, Prism::KeywordHashNode then nil
339
+ else one_line_source(type_arg)
110
340
  end
111
341
 
112
- options = extract_keyword_options(node)
342
+ # Sources, so `default: "anon"` prints as the file writes it.
343
+ options = extract_keyword_sources(node).merge(extract_keyword_nodes(node).slice(:default).transform_values(&:slice))
113
344
 
114
345
  @results << {
115
346
  macro: :attribute,
@@ -120,6 +351,19 @@ module RailsAiContext
120
351
  confidence: confidence_for(node)
121
352
  }
122
353
  end
354
+
355
+ def extract_alias_attribute(node)
356
+ new_name, old_name = extract_symbol_args(node)
357
+ return unless new_name && old_name
358
+
359
+ @results << {
360
+ macro: :alias_attribute,
361
+ attribute: new_name.to_s,
362
+ target: old_name.to_s,
363
+ location: node.location.start_line,
364
+ confidence: confidence_for(node)
365
+ }
366
+ end
123
367
  end
124
368
  end
125
369
  end
@@ -27,7 +27,8 @@ module RailsAiContext
27
27
  when Prism::KeywordHashNode, Prism::HashNode
28
28
  arg.elements.each do |assoc|
29
29
  next unless assoc.is_a?(Prism::AssocNode)
30
- pattern = node_source(assoc.key)
30
+ # `all: :catchall` is the :all address, written as a label.
31
+ pattern = assoc.key.is_a?(Prism::SymbolNode) ? ":#{assoc.key.unescaped}" : node_source(assoc.key)
31
32
  action = extract_value(assoc.value)
32
33
  @results << {
33
34
  type: :routing,
@@ -6,6 +6,8 @@ module RailsAiContext
6
6
  # Call sites by name, wherever they appear: inside a def, a lambda, a
7
7
  # callback block. The macro listener only sees class-body calls.
8
8
  class MethodCallListener < BaseListener
9
+ include OwnerScope
10
+
9
11
  def initialize(names: nil, pattern: nil)
10
12
  super()
11
13
  @names = names&.map(&:to_sym)&.to_set
@@ -20,9 +22,13 @@ module RailsAiContext
20
22
  name: name.to_s,
21
23
  receiver: node.receiver&.slice,
22
24
  line: node.location.start_line,
25
+ offset: node.location.start_offset,
23
26
  arguments: extract_arg_values(node),
27
+ computed: computed_arguments(node),
28
+ constants: constant_arguments(node),
24
29
  options: extract_keyword_sources(node),
25
- snippet: node.slice.lines.first.to_s.strip
30
+ snippet: node.slice.lines.first.to_s.strip,
31
+ owner: @owner_stack.dup
26
32
  }
27
33
  end
28
34
  end