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
@@ -76,10 +76,18 @@ module RailsAiContext
76
76
  end
77
77
 
78
78
  # A tsconfig's compilerOptions as its `extends` chain settles them, and
79
- # the directory each option was declared in; nil when unreadable.
80
- def compiler_options(path, seen = Set.new)
79
+ # the directory each option was declared in; nil when unreadable. With
80
+ # `within`, a config is only read through PackageJson.outside_file in that directory.
81
+ def compiler_options(path, seen = Set.new, within: nil)
81
82
  return nil if path.nil? || seen.include?(path) || seen.size > MAX_EXTENDS
82
83
 
84
+ if within
85
+ full = File.expand_path(path)
86
+ return nil unless full.start_with?("#{within}/")
87
+
88
+ path = RailsAiContext::PackageJson.outside_file(within, full.delete_prefix("#{within}/"))&.to_s or return nil
89
+ end
90
+
83
91
  seen << path
84
92
  text = RailsAiContext::SafeFile.read(path) or return nil
85
93
  config = JSON.parse(jsonc(text))
@@ -89,7 +97,7 @@ module RailsAiContext
89
97
  dirs = {}
90
98
  # A config's own value replaces what it extends; a later `extends` entry replaces an earlier one.
91
99
  Array(config["extends"]).each do |spec|
92
- parent, parent_dirs = compiler_options(extended_config(spec.to_s, path), seen)
100
+ parent, parent_dirs = compiler_options(extended_config(spec.to_s, path), seen, within: within)
93
101
  options.merge!(parent.to_h)
94
102
  dirs.merge!(parent_dirs.to_h)
95
103
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
4
-
5
3
  module RailsAiContext
6
4
  module Introspectors
7
5
  # Discovers multi-database configuration: multiple databases, replicas,
@@ -10,10 +8,6 @@ module RailsAiContext
10
8
  extend StaticTier
11
9
  static_tier :files_only
12
10
 
13
- ERB_SENTINEL = "__rails_ai_context_erb__"
14
- # `ENV["X"].presence || "mysql2"`: the literal is what runs with the variable unset.
15
- ERB_DEFAULT = /\|\|\s*(["'])([\w.-]+)\1\s*\z/
16
-
17
11
  # @return [Hash] multi-database configuration
18
12
  def call
19
13
  dbs = discover_databases
@@ -63,7 +57,7 @@ module RailsAiContext
63
57
  result[:shard_names] = shard_names if shard_names.any?
64
58
 
65
59
  # Extract shard config from model source via AST
66
- SourceScan.each(root, kind: "app/models").each do |record|
60
+ model_sources.each do |record|
67
61
  ast = SourceIntrospector.walk_source(record.source, {
68
62
  connects: -> { Listeners::GenericMacroListener.new(:connects_to) }
69
63
  })
@@ -83,16 +77,16 @@ module RailsAiContext
83
77
 
84
78
  def detect_model_connections
85
79
  connections = []
86
- SourceScan.classes(root, kind: "app/models").each do |model_name, record|
80
+ model_classes.each do |model_name, record|
87
81
  ast = SourceIntrospector.walk_source(record.source, {
88
- connects_to: -> { Listeners::GenericMacroListener.new(:connects_to) },
82
+ connects_to: -> { Listeners::ConditionalMacroListener.new(:connects_to) },
89
83
  connected_to: -> { Listeners::GenericMacroListener.new(:connected_to) }
90
84
  })
91
85
 
92
86
  if ast[:connects_to].any?
93
87
  hit = ast[:connects_to].first
94
88
  connects_to_text = hit[:options].map { |k, v| "#{k}: #{format_connects_value(v)}" }.join(", ")
95
- connections << { model: model_name, connects_to: connects_to_text }
89
+ connections << { model: model_name, connects_to: connects_to_text, condition: hit[:condition] }.compact
96
90
  end
97
91
 
98
92
  if ast[:connected_to].any?
@@ -130,64 +124,22 @@ module RailsAiContext
130
124
  end
131
125
  end
132
126
 
133
- # Read as YAML so anchors, merge keys and comments follow the file format itself.
134
127
  def file_databases
135
- config = database_yml_env
136
- return [] unless config.is_a?(Hash) && config.any?
128
+ databases = RailsAiContext::DatabaseYml.databases(root)
129
+ # Rails builds the primary from DATABASE_URL when the file has no entry for the env.
130
+ return ENV["DATABASE_URL"].to_s.empty? ? [] : [ database_entry("primary", {}) ] if databases.empty?
137
131
 
138
- # Rails' own rule: an env whose values are all Hashes names one
139
- # database per key, anything else is a single primary config.
140
- if config.values.all? { |value| value.is_a?(Hash) }
141
- config.map { |name, entry| database_entry(name, entry) }
142
- else
143
- [ database_entry("primary", config) ]
144
- end
132
+ databases.map { |name, entry| database_entry(name, entry) }
145
133
  end
146
134
 
147
135
  def database_entry(name, entry)
148
- adapter, from_default = adapter_value(entry["adapter"])
136
+ adapter, from_default = RailsAiContext::DatabaseYml.adapter(name, entry)
149
137
  info = { name: name.to_s, adapter: adapter }
150
138
  info[:adapter_default] = true if from_default
151
139
  info[:replica] = true if entry["replica"] == true
152
140
  info
153
141
  end
154
142
 
155
- # An ERB-computed value is unknown, unless the whole value is one tag carrying its own
156
- # literal default, which the sentinel keeps; two tags compose into something neither said.
157
- def adapter_value(value)
158
- return [ nil, false ] if value.nil?
159
-
160
- text = value.to_s
161
- return [ text, false ] unless text.include?(ERB_SENTINEL)
162
- return [ nil, false ] unless text.start_with?(ERB_SENTINEL) && text.scan(ERB_SENTINEL).size == 1
163
-
164
- literal = text.delete_prefix(ERB_SENTINEL)
165
- literal.empty? ? [ nil, false ] : [ literal, true ]
166
- end
167
-
168
- def database_yml_env
169
- path = File.join(root, "config/database.yml")
170
- return nil unless File.exist?(path)
171
-
172
- content = RailsAiContext::SafeFile.read(path)
173
- return nil unless content
174
-
175
- data = YAML.safe_load(neutralize_erb(content), aliases: true, permitted_classes: [ Symbol ])
176
- return nil unless data.is_a?(Hash)
177
-
178
- data[RailsAiContext.environment_name]
179
- rescue => e
180
- RailsAiContext.debug_fail(e, nil, label: "database_yml")
181
- end
182
-
183
- # ERB never runs: an output tag becomes an unknown marker, other tags go, and both keep
184
- # their newlines so the rest of the file parses at its written indentation.
185
- def neutralize_erb(content)
186
- content.gsub(RailsAiContext::ErbSource::TAG) do |tag|
187
- (tag.start_with?("<%=") ? ERB_SENTINEL + Regexp.last_match(1).to_s[ERB_DEFAULT, 2].to_s : "") + ("\n" * tag.count("\n"))
188
- end
189
- end
190
-
191
143
  def anonymize_db_name(name)
192
144
  return name unless name
193
145
 
@@ -124,16 +124,13 @@ module RailsAiContext
124
124
  RailsAiContext.debug_fail(e, {}, label: "detect_server_timing")
125
125
  end
126
126
 
127
- # Rails 8.1 adds `Rails.application.event_reporter`. Report whether it's
128
- # available and the subscriber count. We deliberately do NOT call
129
- # `reporter.tagged` - it's a block-scoped DSL (`reporter.tagged(:x) { … }`)
130
- # that delegates to `TagStack#with_tags` and `yield`s unconditionally, so
131
- # a blockless call raises `LocalJumpError`. There is no keyspace to
132
- # introspect.
127
+ # Rails 8.1's reporter is `Rails.event`; the application object has none.
128
+ # Never call `reporter.tagged`: it yields unconditionally, so a blockless
129
+ # call raises LocalJumpError.
133
130
  def detect_event_reporter
134
- return { available: false } unless app.respond_to?(:event_reporter) && app.event_reporter
131
+ reporter = Rails.event if Rails.respond_to?(:event)
132
+ return { available: false } unless reporter
135
133
 
136
- reporter = app.event_reporter
137
134
  entry = { available: true }
138
135
  entry[:subscriber_count] = reporter.subscribers.size if reporter.respond_to?(:subscribers) && reporter.subscribers.respond_to?(:size)
139
136
  entry
@@ -75,13 +75,14 @@ module RailsAiContext
75
75
  end
76
76
 
77
77
  def load_model_data
78
- SourceScan.each(root, kind: "app/models").filter_map do |record|
78
+ model_sources.filter_map do |record|
79
79
  ast = SourceIntrospector.walk_source(record.source, {
80
80
  classes: Listeners::ClassDefinitionListener,
81
81
  associations: Listeners::AssociationsListener,
82
82
  includes: -> { Listeners::ChainedCallListener.new(:includes) },
83
83
  tree: TREE_LISTENER,
84
- mixins: Listeners::MixinsListener
84
+ mixins: Listeners::MixinsListener,
85
+ counter_culture: -> { Listeners::GenericMacroListener.new(:counter_culture) }
85
86
  })
86
87
 
87
88
  # The listener names the class node alone, so the qualified name is
@@ -111,6 +112,7 @@ module RailsAiContext
111
112
  has_many: has_many,
112
113
  belongs_to: belongs_to,
113
114
  tree_parent_keys: tree_parent_keys(ast, class_name),
115
+ counter_cultures: ast[:counter_culture].map { |c| counter_culture_record(c) },
114
116
  includes_calls: includes_calls
115
117
  }
116
118
  rescue => e
@@ -128,9 +130,9 @@ module RailsAiContext
128
130
  # The parent key a tree macro declares, in the model or its concerns.
129
131
  # The models section cannot carry it statically: the macro is a gem's.
130
132
  def tree_parent_keys(ast, class_name)
131
- collected, = ConcernMacros.collect(root, ast[:mixins], keys: %i[tree], prefer: "model",
132
- within: class_name, cache: (@concern_cache ||= {}),
133
- listeners: TREE_CONCERN_LISTENERS)
133
+ collected = ConcernMacros.collect(root, ast[:mixins], keys: %i[tree], prefer: "model",
134
+ within: class_name, cache: (@concern_cache ||= {}),
135
+ listeners: TREE_CONCERN_LISTENERS).collected
134
136
  (ast[:tree] + Array(collected[:tree])).map do |macro|
135
137
  ((macro[:options] || {})[TREE_MACROS[macro[:macro]]] || "parent_id").to_s
136
138
  end
@@ -322,7 +324,6 @@ module RailsAiContext
322
324
 
323
325
  def detect_missing_counter_cache(model_data, schema_data)
324
326
  missing = []
325
- written = app_written_counter_columns
326
327
 
327
328
  model_data.each do |model|
328
329
  model[:has_many].each do |assoc|
@@ -338,13 +339,13 @@ module RailsAiContext
338
339
  next unless table
339
340
  next unless table[:columns].any? { |c| c[:name] == count_col }
340
341
  next if options.key?(:counter_cache)
341
- next if written.include?(count_col)
342
+ next if app_written_counter_columns.include?(count_col)
342
343
 
343
344
  belongs_to_model = association_model(model_data, assoc, model[:name])
344
345
  next unless belongs_to_model
345
346
  next if belongs_to_model[:belongs_to].any? { |b| b[:options].key?(:counter_cache) }
346
-
347
347
  inverse_name = options[:as] || model[:name].demodulize.underscore
348
+ next if counter_culture_keeps?(belongs_to_model, inverse_name, count_col)
348
349
 
349
350
  missing << {
350
351
  model: model[:name],
@@ -359,6 +360,25 @@ module RailsAiContext
359
360
  missing
360
361
  end
361
362
 
363
+ # counter_culture names its column `<child table>_count` unless column_name says
364
+ # otherwise; a column_name it computes may be this column, so it is not flagged.
365
+ def counter_culture_keeps?(child, relation, column)
366
+ Array(child[:counter_cultures]).any? do |culture|
367
+ next false unless culture[:relation] == relation.to_s
368
+
369
+ named = culture.fetch(:column) { "#{child[:name].demodulize.tableize}_count" }
370
+ named == :computed || named == column
371
+ end
372
+ end
373
+
374
+ # A multi-level `counter_culture [:a, :b]` counts on a further model and has no relation here.
375
+ def counter_culture_record(call)
376
+ node = call[:option_nodes][:column_name]
377
+ record = { relation: call[:args].first&.to_s }
378
+ record[:column] = node.is_a?(Prism::StringNode) || node.is_a?(Prism::SymbolNode) ? node.unescaped : :computed if node
379
+ record
380
+ end
381
+
362
382
  # The class an association declares is the one the app has;
363
383
  # `has_many :remarks, class_name: "Comment"` is answered by Comment, and
364
384
  # never by the Remark the name implies. When no model in the app answers
@@ -2,79 +2,146 @@
2
2
 
3
3
  module RailsAiContext
4
4
  module Introspectors
5
- # Discovers custom rake tasks from lib/tasks/.
5
+ # Discovers the app's own commands: rake tasks from the Rakefile, lib/tasks/
6
+ # and rakelib/, and the generators, generator template overrides and
7
+ # Railties it keeps under lib/.
6
8
  class RakeTaskIntrospector < Base
7
9
  extend StaticTier
8
10
  static_tier :files_only
9
11
 
12
+ # The names Rake looks for, Rakefile first so a case-insensitive disk prints it as written.
13
+ RAKEFILES = %w[Rakefile rakefile Rakefile.rb rakefile.rb].freeze
14
+
10
15
  def call
11
- tasks_dir = File.join(root, "lib/tasks")
12
- return { tasks: [] } unless Dir.exist?(tasks_dir)
16
+ rakefile = RAKEFILES.map { |name| File.join(root, name) }.find { |path| File.file?(path) }
17
+ sources = [ rakefile ].compact
18
+ sources += Dir.glob(File.join(root, "lib/tasks", "**/*.rake")).sort
19
+ # Rake imports rakelib/*.rake, one level only.
20
+ sources += Dir.glob(File.join(root, "rakelib", "*.rake")).sort
21
+
22
+ {
23
+ tasks: merge_by_name(sources.flat_map { |path| parse_rake_file(path) }),
24
+ generators: lib_classes[:generators].sort_by { |g| g[:command] }.presence,
25
+ generator_templates: generator_templates.presence,
26
+ railties: lib_classes[:railties].presence
27
+ }.compact
28
+ end
29
+
30
+ private
31
+
32
+ RAILTIE_BASES = %w[Rails::Railtie].freeze
13
33
 
14
- tasks = Dir.glob(File.join(tasks_dir, "**/*.rake")).sort.flat_map do |path|
15
- parse_rake_file(path, tasks_dir)
34
+ # One pass over lib/ (the cached SourceScan list) for both kinds of class.
35
+ def lib_classes
36
+ @lib_classes ||= { generators: [], railties: [] }.tap do |found|
37
+ SourceScan.each(root, kind: "lib") do |record|
38
+ if record.file.start_with?("lib/generators/") && record.file.end_with?("_generator.rb")
39
+ generator = generator_entry(record)
40
+ found[:generators] << generator if generator
41
+ elsif record.source.include?("Railtie")
42
+ found[:railties].concat(railties_in(record))
43
+ end
44
+ end
16
45
  end
46
+ end
17
47
 
18
- { tasks: tasks }
48
+ # The command Rails::Generators::Base.namespace gives the class:
49
+ # Admin::PageGenerator answers to `admin:page`.
50
+ def generator_entry(record)
51
+ declared = DeclaredConstant.declarations(record.source, path_name: record.path_name).map(&:name).find { |name| name.end_with?("Generator") } or return nil
52
+ entry = { command: "bin/rails generate #{declared.delete_suffix('Generator').underscore.tr('/', ':')}", file: record.file }
53
+ usage = SafeFile.read(File.join(File.dirname(record.path), "USAGE"))
54
+ line = usage&.lines&.map(&:strip)&.find { |text| !text.empty? && text != "Description:" }
55
+ line ? entry.merge(usage: line) : entry
19
56
  end
20
57
 
21
- private
58
+ def railties_in(record)
59
+ railties = DeclaredConstant.declarations(record.source, path_name: record.path_name).select { |d| RAILTIE_BASES.include?(d.superclass.to_s.delete_prefix("::")) }
60
+ return [] if railties.empty?
61
+
62
+ calls = SourceIntrospector.walk_source(record.source, { calls: -> { Listeners::GenericMacroListener.new(:initializer, :rake_tasks) } })[:calls]
63
+ initializers = calls.select { |c| c[:macro] == :initializer }.filter_map { |c| c[:values].first if c[:values].first.is_a?(String) }
64
+ railties.map do |railtie|
65
+ { name: railtie.name, file: record.file, initializers: initializers, rake_tasks: calls.any? { |c| c[:macro] == :rake_tasks } }
66
+ end
67
+ end
22
68
 
23
- def parse_rake_file(path, base_dir)
24
- content = RailsAiContext::SafeFile.read(path)
25
- return [ { file: path.sub("#{base_dir}/", ""), error: "unreadable" } ] unless content
26
- relative = path.sub("#{base_dir}/", "")
69
+ # Rails adds lib/templates to every generator's source paths ahead of its
70
+ # own, so a file at lib/templates/<namespace>/ replaces that generator's template.
71
+ def generator_templates
72
+ dir = File.join(root, "lib", "templates")
73
+ return [] unless File.directory?(dir) && SafePath.contained?(File.realpath(dir), File.realpath(root))
27
74
 
28
- ast_data = SourceIntrospector.walk(path, { rake: -> { Listeners::RakeTaskDslListener.new } })
29
- results = ast_data[:rake]
75
+ Dir.glob(File.join(dir, "**", "*")).sort.filter_map do |path|
76
+ next unless File.file?(path) && SafePath.contained?(File.realpath(path), File.realpath(root))
30
77
 
31
- # Track namespace and desc ordering to associate descs with tasks
32
- last_desc = nil
33
- current_namespace = []
34
- namespace_indents = []
35
- tasks = []
78
+ relative = path.delete_prefix("#{dir}/")
79
+ namespace = File.dirname(relative)
80
+ next if namespace == "."
36
81
 
37
- # RakeTaskDslListener returns tasks, descs and namespaces in source
38
- # order but without block scope, so where a `namespace` block closes is
39
- # tracked by indentation. Regex over the line, not over Ruby structure.
40
- line_events = {}
41
- results.each { |r| (line_events[r[:location]] ||= []) << r }
82
+ { file: "lib/templates/#{relative}", generator: namespace.tr("/", ":") }
83
+ end
84
+ end
42
85
 
43
- content.each_line.with_index(1) do |line, line_no|
44
- indent = line.match(/^(\s*)/)[1].length
86
+ # Rake keeps one task per name: a later definition adds its prerequisites and
87
+ # description, and replaces the arguments only when it names some.
88
+ def merge_by_name(entries)
89
+ merged = {}
90
+ entries.each do |entry|
91
+ next merged[entry.object_id] = entry if entry[:error]
45
92
 
46
- if line.match?(/^\s*end\b/) && namespace_indents.any? && indent <= namespace_indents.last
47
- current_namespace.pop
48
- namespace_indents.pop
49
- end
93
+ task = merged[entry[:name]] ||= { name: entry[:name], comments: [], file: entry[:file] }
94
+ comment = entry[:description]&.strip
95
+ task[:comments] << comment if comment.present? && !task[:comments].include?(comment)
96
+ task[:dependencies] = Array(task[:dependencies]) | entry[:dependencies] if entry[:dependencies]
97
+ task[:args] = entry[:args] if entry[:args]
98
+ end
99
+ merged.values.map do |task|
100
+ next task if task[:error]
50
101
 
51
- next unless line_events[line_no]
52
-
53
- line_events[line_no].each do |entry|
54
- case entry[:type]
55
- when :namespace
56
- current_namespace.push(entry[:name])
57
- namespace_indents.push(indent)
58
- when :desc
59
- last_desc = entry[:description]
60
- when :task
61
- name = (current_namespace + [ entry[:name] ]).join(":")
62
- task = {
63
- name: name,
64
- description: last_desc,
65
- file: relative
66
- }
67
- task[:dependencies] = entry[:deps] if entry[:deps]&.any?
68
- task[:args] = entry[:args] if entry[:args]&.any?
69
- tasks << task.compact
70
- last_desc = nil
71
- end
102
+ comments = task.delete(:comments)
103
+ task.merge(description: comments.filter_map { |c| first_sentence(c) }.join(" / ").presence).compact
104
+ end
105
+ end
106
+
107
+ # Rake::Task#first_sentence, which rake -T prints.
108
+ def first_sentence(text)
109
+ text.split(/(?<=\w)(\.|!)[ \t]|(\.$|!)|\n/).first
110
+ end
111
+
112
+ def parse_rake_file(path)
113
+ relative = path.delete_prefix("#{root}/")
114
+ content, located = RailsAiContext::SafePath.read(relative, under: root)
115
+ return [] if %i[outside sensitive traversal].include?(located.refusal)
116
+ return [ { file: relative, error: "unreadable" } ] unless content
117
+
118
+ ast_data = SourceIntrospector.walk_source(content, { rake: -> { Listeners::RakeTaskDslListener.new } })
119
+ results = ast_data[:rake]
120
+
121
+ # Rake's desc applies to the next task defined, whatever namespace it is in.
122
+ last_desc = nil
123
+ scopes = []
124
+ tasks = []
125
+ results.each do |entry|
126
+ scopes.pop while scopes.any? && entry[:offset] >= scopes.last[:end_offset]
127
+
128
+ case entry[:type]
129
+ when :namespace
130
+ scopes.push(entry)
131
+ when :desc
132
+ last_desc = entry[:description]
133
+ when :task
134
+ task = { name: (scopes.map { |ns| ns[:name] } + [ entry[:name] ]).join(":"), description: last_desc, file: relative }
135
+ task[:dependencies] = entry[:deps] if entry[:deps]&.any?
136
+ task[:args] = entry[:args] if entry[:args]&.any?
137
+ tasks << task.compact
138
+ last_desc = nil
72
139
  end
73
140
  end
74
141
 
75
142
  tasks
76
143
  rescue => e
77
- [ { file: path.sub("#{base_dir}/", ""), error: e.message } ]
144
+ [ { file: relative, error: e.message } ]
78
145
  end
79
146
  end
80
147
  end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ # Recurring tasks from each scheduler's own file, read the way that gem reads it.
6
+ module RecurringSchedules
7
+ module_function
8
+
9
+ # `assignments` returns a config file's ConfigAssignmentListener hits from the caller's own walk, [] when it never names good_job.
10
+ def read(root, assignments)
11
+ solid_queue(root) + sidekiq_cron(root) + sidekiq_scheduler(root) + good_job(root, assignments) + whenever(root)
12
+ end
13
+
14
+ # Solid Queue takes the section named for the environment, else the whole file,
15
+ # and keeps only the entries that carry a `schedule`.
16
+ def solid_queue(root)
17
+ file = "config/recurring.yml"
18
+ data = yaml(root, file)
19
+ return [] unless data.is_a?(Hash)
20
+
21
+ data.flat_map do |key, value|
22
+ next [] unless value.is_a?(Hash)
23
+ next [ task(file, key, value, :schedule) ].compact if value.key?("schedule")
24
+
25
+ value.filter_map { |name, options| task(file, name, options, :schedule, env: key) }
26
+ end
27
+ end
28
+
29
+ def sidekiq_cron(root)
30
+ %w[config/schedule.yml config/schedule.yaml config/sidekiq_cron.yml].flat_map do |file|
31
+ named_tasks(yaml(root, file)).filter_map { |name, options| task(file, name, options, :cron) }
32
+ end
33
+ end
34
+
35
+ # sidekiq-scheduler runs a job by its entry's name when no class is given.
36
+ def sidekiq_scheduler(root)
37
+ file = "config/sidekiq.yml"
38
+ data = yaml(root, file)
39
+ return [] unless data.is_a?(Hash)
40
+
41
+ section = data["scheduler"].is_a?(Hash) ? data["scheduler"]["schedule"] : data["schedule"]
42
+ named_tasks(section).filter_map do |name, options|
43
+ next unless options.is_a?(Hash)
44
+
45
+ kind = SCHEDULER_KEYS.find { |key| options.key?(key.to_s) } or next
46
+ task(file, name, { "class" => name }.merge(options), kind)
47
+ end
48
+ end
49
+
50
+ SCHEDULER_KEYS = %i[cron every at in interval].freeze
51
+
52
+ # In the order Rails loads them; initializers at any depth, sorted by path.
53
+ GOOD_JOB_FILES = %w[config/application.rb config/environments/*.rb config/initializers/**/*.rb].freeze
54
+
55
+ # `config.good_job.cron = {...}`, or entries merged into it (apps also merge! in after_initialize).
56
+ GOOD_JOB_CRON = [ %i[good_job cron], %i[good_job cron merge!], %i[good_job cron update] ].freeze
57
+
58
+ # `cron =` replaces the hash, dropping what earlier files set; an environment
59
+ # file's assignment drops only that file's earlier entries.
60
+ def good_job(root, assignments)
61
+ GOOD_JOB_FILES.flat_map { |pattern| Dir.glob(pattern, base: root.to_s).sort }.each_with_object([]) do |file, tasks|
62
+ env = File.basename(file, ".rb") if file.start_with?("config/environments/")
63
+ assignments.call(file).each do |hit|
64
+ next unless GOOD_JOB_CRON.include?(hit[:path])
65
+
66
+ tasks.reject! { |entry| env.nil? || entry[:file] == file } if hit[:assignment] && !hit[:condition]
67
+ next unless hit[:value].is_a?(Hash)
68
+
69
+ tasks.concat(hit[:value].filter_map { |name, options| task(file, name, stringify(options), :cron, env: env) })
70
+ end
71
+ end
72
+ end
73
+
74
+ WHENEVER_JOBS = %i[runner rake command].freeze
75
+
76
+ # `every 1.day, at: "4:30 am" do runner "CleanupJob.perform_later" end`: the job is
77
+ # the constant the runner's code starts with.
78
+ def whenever(root)
79
+ file = "config/schedule.rb"
80
+ source = read_file(root, file) or return []
81
+ hits = SourceIntrospector.walk_source(source, {
82
+ every: -> { Listeners::GenericMacroListener.new(:every, *WHENEVER_JOBS) }
83
+ })[:every]
84
+ everies = hits.select { |hit| hit[:macro] == :every }.to_h { |hit| [ hit[:offset], hit ] }
85
+ hits.filter_map do |hit|
86
+ every = everies[hit[:parent_offset]]
87
+ code = hit[:values].first
88
+ next unless every && WHENEVER_JOBS.include?(hit[:macro]) && code.is_a?(String)
89
+
90
+ klass = code[/\A\s*(?:::)?([A-Z]\w*(?:::[A-Z]\w*)*)\./, 1] if hit[:macro] == :runner
91
+ { class: klass, command: hit[:macro] == :runner ? code : "#{hit[:macro]} #{code}",
92
+ schedule: whenever_schedule(every), file: file }.compact
93
+ end
94
+ end
95
+
96
+ def whenever_schedule(every)
97
+ text = "every #{every[:values].first}"
98
+ at = every[:option_values][:at]
99
+ at ? "#{text} at #{at}" : text
100
+ end
101
+
102
+ def task(file, name, options, kind, env: nil)
103
+ return nil unless options.is_a?(Hash)
104
+
105
+ options = options.transform_values { |value| computed?(value) ? :computed : value }
106
+ schedule = options[kind.to_s]
107
+ return nil if schedule.nil?
108
+
109
+ # sidekiq-scheduler's `every: ['5m', first_in: '4m']`: the interval, then its options.
110
+ schedule = Array(schedule).flat_map { |part| part.is_a?(Hash) ? part.map { |key, value| "#{key}: #{value}" } : [ part ] }.join(", ")
111
+
112
+ klass = options["class"] || options["klass"]
113
+ computed = ("class" if klass == :computed) || ("command" if options["command"] == :computed)
114
+ klass = nil if klass == :computed
115
+ command = options["command"] unless options["command"] == :computed
116
+ { name: computed?(name.to_s) ? "computed" : name.to_s, class: klass&.to_s&.delete_prefix("::"), command: command&.to_s,
117
+ computed: computed, schedule: schedule.to_s, env: computed?(env.to_s) ? "computed" : env&.to_s, file: file }.compact
118
+ end
119
+
120
+ ERB_OUTPUT = ConfigYaml::ERB_OUTPUT
121
+
122
+ def computed?(value)
123
+ value == RailsAiContext::Confidence::INFERRED || ConfigYaml.marked?(value) ||
124
+ (value.is_a?(Array) && value.any? { |part| computed?(part) }) ||
125
+ (value.is_a?(Hash) && value.values.any? { |part| computed?(part) })
126
+ end
127
+
128
+ # sidekiq-cron accepts a hash keyed by name or a list of hashes that carry it.
129
+ def named_tasks(data)
130
+ case data
131
+ when Hash then data.to_a
132
+ when Array then data.filter_map { |entry| [ entry["name"], entry ] if entry.is_a?(Hash) }
133
+ else []
134
+ end
135
+ end
136
+
137
+ def stringify(value)
138
+ value.is_a?(Hash) ? value.to_h { |key, inner| [ key.to_s, inner ] } : value
139
+ end
140
+
141
+ def yaml(root, file)
142
+ ConfigYaml.read(root, file, label: "schedule", marker: ERB_OUTPUT)
143
+ end
144
+
145
+ def read_file(root, file)
146
+ SafePath.read(file, under: root.to_s).first
147
+ end
148
+ end
149
+ end
150
+ end