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,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ module Listeners
6
+ # Class-body macros that define a constructor: T::Struct's const/prop,
7
+ # Dry::Struct's attribute, dry-initializer's param/option and the
8
+ # attr_extras initializers. Each record carries the parameters the macro
9
+ # adds, as [kind, name, default source], and the class it is written in.
10
+ # Whether the macro is that library's is the consumer's call: `attribute`
11
+ # is also ActiveModel's.
12
+ class ConstructorMacroListener < GenericMacroListener
13
+ include OwnerScope
14
+
15
+ STRUCT_MACROS = %i[const prop attribute attribute?].freeze
16
+ DRY_INITIALIZER_MACROS = %i[param option].freeze
17
+ ATTR_EXTRAS_MACROS = %i[
18
+ attr_initialize pattr_initialize vattr_initialize rattr_initialize aattr_initialize
19
+ attr_private_initialize attr_value_initialize attr_reader_initialize attr_accessor_initialize
20
+ method_object static_facade
21
+ ].freeze
22
+
23
+ def initialize
24
+ super(STRUCT_MACROS + DRY_INITIALIZER_MACROS + ATTR_EXTRAS_MACROS + %i[extend])
25
+ end
26
+
27
+ def on_call_node_enter(node)
28
+ count = @results.size
29
+ super
30
+ @results[count..].each do |record|
31
+ record[:owner] = @owner_stack.dup
32
+ record[:source] = one_line_source(node)
33
+ record[:params] = params_for(node)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def params_for(node)
40
+ args = Array(node.arguments&.arguments)
41
+ case node.name
42
+ when :const, :prop then struct_param(args, extract_keyword_nodes(node))
43
+ when :attribute, :attribute? then dry_struct_param(node.name, args)
44
+ when :param, :option then dry_initializer_param(node.name, args, extract_keyword_nodes(node))
45
+ when :extend then []
46
+ else attr_extras_params(node.name == :static_facade ? args.drop(1) : args)
47
+ end
48
+ end
49
+
50
+ # A T::Struct prop is optional with a default, a factory, or a nilable type.
51
+ def struct_param(args, options)
52
+ name = literal_string(args.first) or return []
53
+ default = options[:default] || options[:factory]
54
+ return [ [ :key, name, default_text(default) ] ] if default
55
+ return [ [ :key, name, "nil" ] ] if args[1] && !args[1].is_a?(Prism::KeywordHashNode) && args[1].slice.start_with?("T.nilable(")
56
+
57
+ [ [ :keyreq, name, nil ] ]
58
+ end
59
+
60
+ def dry_struct_param(macro, args)
61
+ name = literal_string(args.first) or return []
62
+ return [ [ :key, name, "nil" ] ] if macro == :attribute?
63
+
64
+ default = type_default(args[1])
65
+ default ? [ [ :key, name, default ] ] : [ [ :keyreq, name, nil ] ]
66
+ end
67
+
68
+ # `Types::String.default("USD")`, anywhere in the type's call chain.
69
+ def type_default(node)
70
+ while node.is_a?(Prism::CallNode)
71
+ return default_text(node.arguments&.arguments&.first) || "..." if node.name == :default
72
+
73
+ node = node.receiver
74
+ end
75
+ nil
76
+ end
77
+
78
+ def dry_initializer_param(macro, args, options)
79
+ name = literal_string(args.first) or return []
80
+ default = (default_text(options[:default]) if options[:default]) ||
81
+ ("nil" if options[:optional].is_a?(Prism::TrueNode))
82
+ positional = macro == :param
83
+ kind = positional ? (default ? :opt : :req) : (default ? :key : :keyreq)
84
+ [ [ kind, name, default ] ]
85
+ end
86
+
87
+ # Symbols are positional; an array holds keywords, `name!` required.
88
+ def attr_extras_params(args)
89
+ args.flat_map do |arg|
90
+ if arg.is_a?(Prism::ArrayNode)
91
+ arg.elements.flat_map { |element| attr_extras_keywords(element) }
92
+ else
93
+ name = literal_string(arg)
94
+ name ? [ [ :req, name, nil ] ] : []
95
+ end
96
+ end
97
+ end
98
+
99
+ def attr_extras_keywords(element)
100
+ if element.is_a?(Prism::KeywordHashNode) || element.is_a?(Prism::HashNode)
101
+ return element.elements.grep(Prism::AssocNode).filter_map do |assoc|
102
+ key = literal_string(assoc.key)
103
+ [ :key, key, assoc.value.slice ] if key
104
+ end
105
+ end
106
+
107
+ name = literal_string(element) or return []
108
+ name.end_with?("!") ? [ [ :keyreq, name.delete_suffix("!"), nil ] ] : [ [ :key, name, "nil" ] ]
109
+ end
110
+
111
+ # A lambda default reads as the value it returns.
112
+ def default_text(node)
113
+ return nil unless node
114
+
115
+ body = node.body if node.is_a?(Prism::LambdaNode)
116
+ body = node.block.body if node.is_a?(Prism::CallNode) && %i[lambda proc].include?(node.name) && node.block.is_a?(Prism::BlockNode)
117
+ statements = body.is_a?(Prism::StatementsNode) ? body.body : []
118
+ statements.size == 1 ? statements.first.slice : node.slice
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ module Listeners
6
+ # Where a test helper points factory_bot, literal paths only:
7
+ #
8
+ # FactoryBot.definition_file_paths = %w[custom/factories] → { replace: true, paths: ["custom/factories"] }
9
+ # FactoryBot.definition_file_paths << "lib/factories" → { replace: false, paths: ["lib/factories"] }
10
+ # FactoryBot.find_definitions / FactoryBot.reload → { load: :find_definitions } / { load: :reload }
11
+ #
12
+ # The path forms are LiteralPaths'; a write with any path it cannot read records `unread: true`.
13
+ class DefinitionFilePathsListener < BaseListener
14
+ include LiteralPaths
15
+
16
+ LOADS = %i[find_definitions reload].freeze
17
+
18
+ def initialize(file: nil)
19
+ super()
20
+ @file = file
21
+ end
22
+
23
+ def on_call_node_enter(node)
24
+ if LOADS.include?(node.name) && factory_bot?(node.receiver)
25
+ @results << { load: node.name }
26
+ elsif node.name == :definition_file_paths= && factory_bot?(node.receiver)
27
+ record(true) { collect_arguments(node) }
28
+ elsif APPENDING.include?(node.name) && setting?(node.receiver)
29
+ record(false) { collect_arguments(node) }
30
+ end
31
+ end
32
+
33
+ def on_call_operator_write_node_enter(node)
34
+ return unless node.read_name == :definition_file_paths && node.binary_operator == :+ && factory_bot?(node.receiver)
35
+
36
+ record(false) { collect_paths(node.value) }
37
+ end
38
+
39
+ private
40
+
41
+ def collect_arguments(node)
42
+ Array(node.arguments&.arguments).map { |argument| collect_paths(argument) }.all?
43
+ end
44
+
45
+ def record(replace)
46
+ before = @results.size
47
+ read = yield
48
+ entry = { replace: replace, paths: @results.slice!(before..) }
49
+ @results << (read ? entry : entry.merge(unread: true))
50
+ end
51
+
52
+ def setting?(node)
53
+ node.is_a?(Prism::CallNode) && node.name == :definition_file_paths && factory_bot?(node.receiver)
54
+ end
55
+
56
+ def factory_bot?(node)
57
+ case node
58
+ when Prism::ConstantReadNode then node.name == :FactoryBot
59
+ when Prism::ConstantPathNode then node.parent.nil? && node.name == :FactoryBot
60
+ else false
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -59,7 +59,7 @@ module RailsAiContext
59
59
 
60
60
  @results << {
61
61
  name: key.to_s,
62
- values: enum_values(assoc.value),
62
+ values: normalize_enum_values(enum_values(assoc.value)),
63
63
  options: options,
64
64
  location: node.location.start_line,
65
65
  confidence: confidence_for(node)
@@ -104,7 +104,7 @@ module RailsAiContext
104
104
  end
105
105
 
106
106
  def enum_modifier_key?(key)
107
- %i[prefix suffix _prefix _suffix default scopes].include?(key)
107
+ %i[prefix suffix _prefix _suffix default _default scopes _scopes].include?(key)
108
108
  end
109
109
  end
110
110
  end
@@ -7,6 +7,7 @@ module RailsAiContext
7
7
  # ENV["KEY"], ENV.fetch("KEY"), ENV.fetch("KEY", "default")
8
8
  class EnvAccessListener < BaseListener
9
9
  def on_call_node_enter(node)
10
+ return extract_creds(node) if creds_receiver?(node.receiver)
10
11
  return unless env_receiver?(node.receiver)
11
12
 
12
13
  case node.name
@@ -19,6 +20,36 @@ module RailsAiContext
19
20
 
20
21
  private
21
22
 
23
+ # Rails 8.2's `Rails.app.creds` checks ENV before the encrypted
24
+ # credentials, and `Rails.app.envs` reads only ENV.
25
+ def creds_receiver?(receiver)
26
+ return false unless receiver.is_a?(Prism::CallNode) && %i[creds envs].include?(receiver.name)
27
+
28
+ app = receiver.receiver
29
+ app.is_a?(Prism::CallNode) && %i[app application].include?(app.name) &&
30
+ app.receiver.is_a?(Prism::ConstantReadNode) && app.receiver.name == :Rails
31
+ end
32
+
33
+ # `require(:database, :host)` reads ENV["DATABASE__HOST"].
34
+ def extract_creds(node)
35
+ return unless %i[require option].include?(node.name)
36
+
37
+ args = node.arguments&.arguments || []
38
+ parts = args.reject { |arg| arg.is_a?(Prism::KeywordHashNode) }
39
+ keys = parts.map { |arg| literal_string(arg) }
40
+ return if keys.empty? || keys.any?(&:nil?)
41
+
42
+ default = extract_keyword_nodes(node)[:default]
43
+ optional = node.name == :option
44
+ @results << {
45
+ method: optional && default.nil? ? "[]" : "fetch",
46
+ key: keys.map(&:upcase).join("__"),
47
+ has_default: !default.nil?,
48
+ default: literal_value(default),
49
+ location: node.location.start_line
50
+ }
51
+ end
52
+
22
53
  def env_receiver?(receiver)
23
54
  receiver.is_a?(Prism::ConstantReadNode) && receiver.name == :ENV
24
55
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ module Listeners
6
+ # ConditionalMacroListener plus `callbacks`: what each positional argument of a filter macro
7
+ # adds, named at the node as the booted tier names the callback it reflects.
8
+ class FilterMacroListener < ConditionalMacroListener
9
+ def on_call_node_enter(node)
10
+ count = @results.size
11
+ super
12
+ @results.last[:callbacks] = callbacks(node) if @results.size > count
13
+ end
14
+
15
+ private
16
+
17
+ # [:name, "x"] for a symbol, string or class; [:object, "Klass"] for an instance;
18
+ # [:lambda] for `-> {}` or `lambda {}`, [:block] for a proc; [:unread, source] for anything else.
19
+ def callbacks(node)
20
+ Array(node.arguments&.arguments).reject { |arg| arg.is_a?(Prism::KeywordHashNode) }.map do |arg|
21
+ if (text = literal_string(arg)) then [ :name, text ]
22
+ elsif proc_argument?(arg) then [ arg.is_a?(Prism::LambdaNode) || arg.name == :lambda ? :lambda : :block ]
23
+ elsif (const = constant_name(arg)) then [ :name, const ]
24
+ elsif (klass = object_name(arg)) then [ :object, klass ]
25
+ else [ :unread, one_line_source(arg) ]
26
+ end
27
+ end
28
+ end
29
+
30
+ # The class the booted tier names an object filter by: `Class` for an anonymous class,
31
+ # an instance's nearest named class (`Class.new(Base) {}.new` is Base's, `Class.new {}.new` Object's).
32
+ def object_name(node)
33
+ return nil unless node.is_a?(Prism::CallNode) && node.name == :new && node.receiver
34
+ return "Class" if %w[Class Struct].include?(constant_name(node.receiver))
35
+
36
+ named_class(node.receiver)
37
+ end
38
+
39
+ def named_class(node)
40
+ return constant_name(node) unless node.is_a?(Prism::CallNode) && node.name == :new
41
+
42
+ case constant_name(node.receiver)
43
+ when "Class" then (superclass = node.arguments&.arguments&.first) ? named_class(superclass) : "Object"
44
+ when "Struct" then "Struct"
45
+ end
46
+ end
47
+
48
+ def constant_name(node)
49
+ constant_path_string(node) if node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ module Listeners
6
+ # Fixture directories a test helper sets, literal paths only:
7
+ #
8
+ # config.fixture_paths = [Rails.root.join("spec/fixtures")]
9
+ # self.fixture_paths << "#{Rails.root}/test/fixtures/"
10
+ # self.fixture_path = "#{::Rails.root}/spec/fixtures"
11
+ # ActiveSupport::TestCase.fixture_paths << File.expand_path("../extra_fx", __dir__)
12
+ #
13
+ # A path built from __dir__ or __FILE__ is read only when the walk is given the helper's
14
+ # `file`, relative to the app root.
15
+ # The path forms are LiteralPaths'. A write with any path it cannot read records :unread,
16
+ # so a caller still knows the setting is set.
17
+ class FixturePathsListener < BaseListener
18
+ include LiteralPaths
19
+
20
+ SETTINGS = %i[fixture_paths fixture_path].to_set.freeze
21
+ ASSIGNING = %i[fixture_paths= fixture_path=].to_set.freeze
22
+
23
+ def initialize(file: nil)
24
+ super()
25
+ @file = file
26
+ end
27
+
28
+ def on_call_node_enter(node)
29
+ if ASSIGNING.include?(node.name)
30
+ return unless setting_receiver?(node.receiver)
31
+ else
32
+ return unless APPENDING.include?(node.name) && fixture_setting?(node.receiver)
33
+ end
34
+
35
+ record_write { Array(node.arguments&.arguments).map { |argument| collect_paths(argument) }.all? }
36
+ end
37
+
38
+ def on_call_operator_write_node_enter(node)
39
+ return unless SETTINGS.include?(node.read_name) && node.binary_operator == :+ && setting_receiver?(node.receiver)
40
+
41
+ record_write { collect_paths(node.value) }
42
+ end
43
+
44
+ private
45
+
46
+ def record_write
47
+ @results << :unread unless yield
48
+ end
49
+
50
+ def fixture_setting?(node)
51
+ node.is_a?(Prism::CallNode) && SETTINGS.include?(node.name) && setting_receiver?(node.receiver)
52
+ end
53
+
54
+ # The test case class itself, named or as self, or RSpec's `config` (the block parameter `RSpec.configure` yields).
55
+ def setting_receiver?(node)
56
+ node.nil? || node.is_a?(Prism::SelfNode) || node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode) ||
57
+ ((node.is_a?(Prism::CallNode) || node.is_a?(Prism::LocalVariableReadNode)) && node.name == :config)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -6,6 +6,7 @@ module RailsAiContext
6
6
  # Detects Gemfile DSL patterns via Prism AST:
7
7
  # gem "name", "version", options...
8
8
  # group :development, :test do ... end
9
+ # ruby "3.3.6", and an :unknown_gems entry for each call that adds gems the file does not name.
9
10
  class GemfileDslListener < BaseListener
10
11
  def initialize
11
12
  super
@@ -20,6 +21,12 @@ module RailsAiContext
20
21
  extract_gem(node)
21
22
  when :source
22
23
  extract_source(node)
24
+ when :eval_gemfile
25
+ extract_eval_gemfile(node)
26
+ when :ruby
27
+ extract_ruby(node)
28
+ when :gemspec
29
+ unknown_gems(node)
23
30
  when :group
24
31
  return unless node.block
25
32
 
@@ -47,7 +54,7 @@ module RailsAiContext
47
54
  return if args.empty?
48
55
 
49
56
  name_arg = args.first
50
- return unless name_arg.is_a?(Prism::StringNode)
57
+ return unknown_gems(node) unless name_arg.is_a?(Prism::StringNode)
51
58
 
52
59
  name = name_arg.unescaped
53
60
  version = nil
@@ -78,6 +85,40 @@ module RailsAiContext
78
85
  }
79
86
  end
80
87
 
88
+ # `eval_gemfile "Gemfile.local"`, or `File.expand_path("x", __dir__)`;
89
+ # Bundler reads the path from the evaluating file's directory.
90
+ def extract_eval_gemfile(node)
91
+ arg = node.arguments&.arguments&.first
92
+ if arg.is_a?(Prism::CallNode) && arg.name == :expand_path && file_constant?(arg.receiver)
93
+ inner = arg.arguments&.arguments || []
94
+ arg = inner.first if inner.size == 2 && dir_call?(inner.last)
95
+ end
96
+ return unknown_gems(node) unless arg.is_a?(Prism::StringNode)
97
+
98
+ @results << {
99
+ type: :eval_gemfile,
100
+ path: arg.unescaped,
101
+ groups: @current_groups.flatten.uniq,
102
+ location: node.location.start_line
103
+ }
104
+ end
105
+
106
+ # Bundler takes `engine:` and `engine_version:` beside the version.
107
+ def extract_ruby(node)
108
+ options = extract_keyword_nodes(node)
109
+ @results << {
110
+ type: :ruby,
111
+ version: literal_string(node.arguments&.arguments&.first),
112
+ engine: literal_string(options[:engine]),
113
+ engine_version: literal_string(options[:engine_version]),
114
+ location: node.location.start_line
115
+ }
116
+ end
117
+
118
+ def unknown_gems(node)
119
+ @results << { type: :unknown_gems, call: node.name, location: node.location.start_line }
120
+ end
121
+
81
122
  def extract_source(node)
82
123
  args = node.arguments&.arguments || []
83
124
  url_arg = args.first
@@ -4,14 +4,18 @@ module RailsAiContext
4
4
  module Introspectors
5
5
  module Listeners
6
6
  class GenericMacroListener < BaseListener
7
- def initialize(*target_methods)
7
+ # `any_receiver` also counts `base.macro`, the shape a mixin's `self.included(base)` hook writes.
8
+ def initialize(*target_methods, block_source: [], call_source: [], any_receiver: false)
8
9
  super()
10
+ @any_receiver = any_receiver
9
11
  @target_methods = target_methods.flatten.map(&:to_sym).to_set
10
12
  @enclosing = []
13
+ @block_source = block_source.to_set
14
+ @call_source = call_source.to_set
11
15
  end
12
16
 
13
17
  def on_call_node_enter(node)
14
- return unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
18
+ return unless @any_receiver || node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
15
19
  return unless @target_methods.include?(node.name)
16
20
 
17
21
  @results << {
@@ -21,6 +25,9 @@ module RailsAiContext
21
25
  options: extract_keyword_options(node),
22
26
  option_values: extract_keyword_sources(node),
23
27
  option_nodes: extract_keyword_nodes(node),
28
+ block: (one_line_source(node.block) if node.block.is_a?(Prism::BlockNode) && @block_source.include?(node.name)),
29
+ # Where a block or a lambda argument opens: the line Proc#source_location gives.
30
+ proc_lines: proc_lines(node),
24
31
  # Offsets, not line numbers: a one-line block puts the parent and
25
32
  # its nested calls on one line, and a consumer pairing them by
26
33
  # line then attaches the second child to the first.
@@ -30,6 +37,9 @@ module RailsAiContext
30
37
  location: node.location.start_line,
31
38
  confidence: confidence_for(node)
32
39
  }
40
+ @results.last[:text] = one_line_source(node) if @call_source.include?(node.name)
41
+ condition = macro_condition
42
+ @results.last[:condition] = condition if condition
33
43
 
34
44
  # A target macro that takes a block encloses whatever the block
35
45
  # declares: `string :title` inside `hash :order_params do ... end`
@@ -40,6 +50,22 @@ module RailsAiContext
40
50
  def on_call_node_leave(node)
41
51
  @enclosing.pop if @enclosing.last.equal?(node)
42
52
  end
53
+
54
+ private
55
+
56
+ def macro_condition; end
57
+
58
+ # A def evaluates to its name, which is what `helper_method def foo` passes.
59
+ def extract_symbol_args(node)
60
+ Array(node.arguments&.arguments).filter_map { |arg| arg.is_a?(Prism::DefNode) ? arg.name : literal_string(arg)&.to_sym }
61
+ end
62
+
63
+ # A call's line is where it opens, as Proc#source_location gives it.
64
+ def proc_lines(node)
65
+ procs = Array(node.arguments&.arguments).select { |arg| proc_argument?(arg) }
66
+ procs << node.block if node.block.is_a?(Prism::BlockNode)
67
+ procs.map { |found| found.location.start_line }
68
+ end
43
69
  end
44
70
  end
45
71
  end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ module Listeners
6
+ # What a Grape API class body declares: its superclass, version and
7
+ # prefix, each endpoint with the namespaces around it and the params
8
+ # block before it, and the APIs it mounts. Whether a class is a Grape API
9
+ # is the consumer's call, since a superclass can be another API class.
10
+ class GrapeApiListener < BaseListener
11
+ VERBS = %i[get post put patch delete head options query].freeze
12
+ NAMESPACES = %i[namespace group resource resources segment route_param].freeze
13
+ PARAMS = %i[requires optional].freeze
14
+
15
+ def initialize
16
+ super
17
+ @scopes = [ nil ]
18
+ @blocks = []
19
+ @pending = []
20
+ end
21
+
22
+ def on_class_node_enter(node)
23
+ name = scope_name(node)
24
+ superclass = node.superclass && (node.superclass.is_a?(Prism::ConstantReadNode) || node.superclass.is_a?(Prism::ConstantPathNode)) ? constant_path_string(node.superclass) : nil
25
+ @results << { kind: :class, owner: name, superclass: superclass }
26
+ @scopes.push(name)
27
+ end
28
+
29
+ def on_class_node_leave(_node)
30
+ @scopes.pop
31
+ end
32
+
33
+ def on_module_node_enter(node)
34
+ @scopes.push(scope_name(node))
35
+ end
36
+
37
+ def on_module_node_leave(_node)
38
+ @scopes.pop
39
+ end
40
+
41
+ def on_call_node_enter(node)
42
+ frame = frame_for(node)
43
+ @blocks.push(frame) if node.block.is_a?(Prism::BlockNode)
44
+ end
45
+
46
+ def on_call_node_leave(node)
47
+ return unless node.block.is_a?(Prism::BlockNode)
48
+
49
+ @pending = [] if @blocks.pop.first == :namespace
50
+ end
51
+
52
+ private
53
+
54
+ # Records what the call declares and returns the frame its block opens.
55
+ def frame_for(node)
56
+ owner = @scopes.last
57
+ return [ :body ] if owner.nil? || node.receiver || in_endpoint?
58
+
59
+ args = Array(node.arguments&.arguments)
60
+ case node.name
61
+ when *VERBS
62
+ @results << { kind: :endpoint, owner: owner, verb: node.name.to_s.upcase, path: literal_string(args.first) || "",
63
+ namespace: namespace, params: inherited_params + @pending }
64
+ @pending = []
65
+ [ :endpoint ]
66
+ when *NAMESPACES
67
+ space = literal_string(args.first)
68
+ if node.name == :route_param && space
69
+ type = keyword(node, :type)
70
+ @pending += [ { name: space, type: type, required: true } ] if type
71
+ space = ":#{space}"
72
+ end
73
+ # Grape declares these on the setting the namespace inherits, so every endpoint inside gets them.
74
+ params, @pending = @pending, []
75
+ [ :namespace, space, params ]
76
+ when :params
77
+ [ :params ]
78
+ when *PARAMS
79
+ if @blocks.last&.first == :params
80
+ type = keyword(node, :type)
81
+ args.filter_map { |arg| literal_string(arg) }.each do |name|
82
+ @pending << { name: name, type: type, required: node.name == :requires }
83
+ end
84
+ end
85
+ [ :body ]
86
+ when :version, :prefix
87
+ value = literal_string(args.first)
88
+ @results << { kind: node.name, owner: owner, value: value, using: keyword(node, :using) || "path" } if value
89
+ [ :body ]
90
+ when :mount
91
+ mounts(args).each { |target, path| @results << { kind: :mount, owner: owner, target: target, path: path, namespace: namespace } }
92
+ [ :body ]
93
+ else
94
+ [ :body ]
95
+ end
96
+ end
97
+
98
+ def in_endpoint?
99
+ @blocks.any? { |frame| frame.first == :endpoint }
100
+ end
101
+
102
+ def namespace
103
+ @blocks.filter_map { |kind, space| space if kind == :namespace }
104
+ end
105
+
106
+ def inherited_params
107
+ @blocks.flat_map { |kind, _, params| kind == :namespace ? params : [] }
108
+ end
109
+
110
+ def keyword(node, key)
111
+ value = extract_keyword_nodes(node)[key]
112
+ value && (literal_string(value) || value.slice)
113
+ end
114
+
115
+ # `mount V1::Users`, `mount V1::Users => "/v1"` and the braced form.
116
+ def mounts(args)
117
+ args.flat_map do |arg|
118
+ case arg
119
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then [ [ constant_path_string(arg), "/" ] ]
120
+ when Prism::KeywordHashNode, Prism::HashNode
121
+ arg.elements.grep(Prism::AssocNode).filter_map do |assoc|
122
+ next unless assoc.key.is_a?(Prism::ConstantReadNode) || assoc.key.is_a?(Prism::ConstantPathNode)
123
+
124
+ [ constant_path_string(assoc.key), literal_string(assoc.value) || "/" ]
125
+ end
126
+ else []
127
+ end
128
+ end
129
+ end
130
+
131
+ def scope_name(node)
132
+ nested_name(node.constant_path, @scopes.last)
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end