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
@@ -6,15 +6,15 @@ require "prism"
6
6
 
7
7
  module RailsAiContext
8
8
  # Thread-safe AST parse cache backed by Concurrent::Map.
9
- # Keyed by path + content hash + mtime - automatically invalidates
10
- # when file content changes. Used by all Prism-based introspectors.
9
+ # Keyed by content hash, so a file and the same text read as a string share one
10
+ # parse, and a changed file misses. Used by all Prism-based introspectors.
11
11
  # A settled file's stat stands in for the read and hash (see RACY_WINDOW).
12
12
  #
13
13
  # Bounded: evicts entries when MAX_SIZE is exceeded, and a file's recorded
14
14
  # stat goes with its parse.
15
15
  module AstCache
16
16
  STORE = Concurrent::Map.new
17
- SEEN = Concurrent::Map.new # path => [stat signature, STORE key, whether the stat can be trusted]
17
+ SEEN = Concurrent::Map.new # path, or [path, ruby grammar] => [stat signature, STORE key, whether the stat can be trusted]
18
18
  MAX_SIZE = 500
19
19
  EVICTION_MUTEX = Mutex.new
20
20
 
@@ -27,12 +27,15 @@ module RailsAiContext
27
27
  #
28
28
  # Reads content first, then checks size - avoids TOCTOU race where the
29
29
  # file could change between File.size and File.read.
30
- def self.parse(path)
31
- # An unchanged file answers from its stat: the same concern file reaches
32
- # every model's walk, and reading and hashing it each time dominated the
33
- # model tier on Canvas. Within one run the stat is asked once.
30
+ #
31
+ # `ruby:` is the Ruby version whose grammar decides what is a syntax error;
32
+ # nil parses as the newest Ruby prism knows.
33
+ def self.parse(path, ruby: nil)
34
+ version = prism_version(ruby)
35
+ seen_key = version ? [ path, version ] : path
36
+ # An unchanged file answers from its stat; one concern file reaches every model's walk.
34
37
  signature = RunCache.fetch([ :stat_signature, path.to_s ]) { stat_signature(path) }
35
- seen = SEEN[path]
38
+ seen = SEEN[seen_key]
36
39
  if seen && seen[2] && seen.first == signature && (cached = STORE[seen[1]])
37
40
  return cached
38
41
  end
@@ -42,16 +45,15 @@ module RailsAiContext
42
45
  size = content.bytesize
43
46
  raise ArgumentError, "File too large for AST parsing: #{path} (#{size} bytes, max #{MAX_PARSE_SIZE})" if size > MAX_PARSE_SIZE
44
47
 
45
- mtime = File.mtime(path).to_i
46
- key = "#{path}:#{Digest::SHA256.hexdigest(content)}:#{mtime}"
48
+ key = content_key(content, version)
47
49
 
48
50
  cached = STORE[key]
49
51
  unless cached
50
52
  # Evict BEFORE inserting to avoid running inside compute_if_absent
51
53
  evict_if_full
52
- cached = STORE.compute_if_absent(key) { Prism.parse(content) }
54
+ cached = STORE.compute_if_absent(key) { prism_parse(content, version) }
53
55
  end
54
- SEEN[path] = [ signature, key, settled?(signature, read_at) ]
56
+ SEEN[seen_key] = [ signature, key, settled?(signature, read_at) ]
55
57
  cached
56
58
  end
57
59
 
@@ -83,18 +85,51 @@ module RailsAiContext
83
85
 
84
86
  # Parse a Ruby source string, cached by content digest, so every extractor
85
87
  # handed the same text shares one parse.
86
- def self.parse_string(source)
87
- return Prism.parse(source) if source.bytesize > MAX_PARSE_SIZE
88
+ def self.parse_string(source, ruby: nil)
89
+ version = prism_version(ruby)
90
+ return prism_parse(source, version) if source.bytesize > MAX_PARSE_SIZE
88
91
 
89
- key = "string:#{Digest::SHA256.hexdigest(source)}"
92
+ key = content_key(source, version)
90
93
 
91
94
  cached = STORE[key]
92
95
  return cached if cached
93
96
 
94
97
  evict_if_full
95
98
 
96
- STORE.compute_if_absent(key) { Prism.parse(source) }
99
+ STORE.compute_if_absent(key) { prism_parse(source, version) }
100
+ end
101
+
102
+ def self.content_key(source, version)
103
+ "src:#{Digest::SHA256.hexdigest(source)}#{":#{version}" if version}"
104
+ end
105
+ private_class_method :content_key
106
+
107
+ OLDEST_GRAMMAR = "3.3"
108
+ GRAMMARS = Concurrent::Map.new # major.minor => whether the loaded prism parses it
109
+
110
+ # The grammar prism parses `ruby` with: its own major.minor, the oldest prism
111
+ # knows for an older Ruby, and nil (the newest) for one newer than prism knows.
112
+ # ponytail: Ruby 3.1 and 3.2 parse as 3.3; syntax only they accept reads as an error.
113
+ def self.prism_version(ruby)
114
+ wanted = ruby.to_s.b[/\A\d+\.\d+/]
115
+ return nil unless wanted
116
+
117
+ wanted = OLDEST_GRAMMAR if Gem::Version.new(wanted) < Gem::Version.new(OLDEST_GRAMMAR)
118
+ wanted if GRAMMARS.compute_if_absent(wanted) { supported?(wanted) }
119
+ end
120
+
121
+ def self.supported?(version)
122
+ Prism.parse("", version: version)
123
+ true
124
+ rescue ArgumentError
125
+ false
126
+ end
127
+ private_class_method :supported?
128
+
129
+ def self.prism_parse(source, version)
130
+ version ? Prism.parse(source, version: version) : Prism.parse(source)
97
131
  end
132
+ private_class_method :prism_parse
98
133
 
99
134
  # Clear the entire cache.
100
135
  def self.clear
@@ -114,8 +149,8 @@ module RailsAiContext
114
149
  return if STORE.size < MAX_SIZE
115
150
  keys = STORE.keys
116
151
  evicted = keys.first(keys.size / 4).each { |k| STORE.delete(k) }.to_set
117
- # A stat that points at an evicted parse can only send the next call to
118
- # read the file, so it goes with it, and SEEN stays within MAX_SIZE.
152
+ # A stat goes with the parse it points at; files with the same bytes share
153
+ # one parse, so SEEN can outgrow STORE but not the app's file count.
119
154
  SEEN.each_pair { |path, entry| SEEN.delete(path) if evicted.include?(entry[1]) }
120
155
  end
121
156
  end
@@ -6,6 +6,8 @@ module RailsAiContext
6
6
  module BaseMixins
7
7
  # `path` is nil when the declaring file is not the app's (a gem's module).
8
8
  Mixin = Struct.new(:name, :path, :macro)
9
+ # A base body's statements, the base it runs in, and its file's place in load order (sorted, as Rails loads initializers).
10
+ Body = Struct.new(:statements, :target, :order)
9
11
 
10
12
  TARGETS = %w[ActiveRecord::Base ApplicationRecord].freeze
11
13
  HOOK = :active_record
@@ -21,12 +23,22 @@ module RailsAiContext
21
23
  RunCache.fetch([ :base_mixins, root ]) { discover(root) }
22
24
  end
23
25
 
24
- def discover(root)
25
- scanned_files(root).flat_map do |file|
26
- source = SafeFile.read(file)
27
- next [] unless source && MARKERS.any? { |marker| source.include?(marker) }
26
+ # Each model base body the app writes outside app/models, where a `def self.x` or
27
+ # `class << self` defines a class method every model under that base has.
28
+ # @return [Array<Body>]
29
+ def bodies(root)
30
+ root = File.expand_path(root.to_s)
31
+ RunCache.fetch([ :base_bodies, root ]) do
32
+ marked(root).each_with_index.flat_map do |(_, tree), order|
33
+ base_bodies(tree).map { |statements, target, _| Body.new(statements, target, order) }
34
+ end
35
+ end
36
+ rescue StandardError => e
37
+ RailsAiContext.debug_fail(e, [], label: "BaseMixins.bodies")
38
+ end
28
39
 
29
- tree = AstCache.parse(file).value
40
+ def discover(root)
41
+ marked(root).flat_map do |file, tree|
30
42
  mixed_in(tree).map { |macro, name| Mixin.new(name, declaring_file(root, name, file), macro) }
31
43
  end.uniq(&:name)
32
44
  rescue StandardError => e
@@ -34,6 +46,17 @@ module RailsAiContext
34
46
  end
35
47
  private_class_method :discover
36
48
 
49
+ # [file, tree] for each scanned file that names a model base; models and bodies both ask.
50
+ def marked(root)
51
+ RunCache.fetch([ :base_mixins_marked, root ]) do
52
+ scanned_files(root).filter_map do |file|
53
+ source = SafeFile.read(file)
54
+ [ file, AstCache.parse(file).value ] if source && MARKERS.any? { |marker| source.include?(marker) }
55
+ end
56
+ end
57
+ end
58
+ private_class_method :marked
59
+
37
60
  # Everywhere the app configures itself but app/models: a model file's includes are
38
61
  # its own, and the model walk reads that file already.
39
62
  def scanned_files(root)
@@ -50,25 +73,31 @@ module RailsAiContext
50
73
  private_class_method :scanned_files
51
74
 
52
75
  # [macro, constant] for each module the tree mixes into a model base:
53
- # sent to it, run in its `on_load` hook, or written in its reopened body.
76
+ # sent to it, run in its `on_load` hook, or written in its own body.
54
77
  def mixed_in(tree)
55
78
  Introspectors::AstWalk.each(tree).flat_map do |node|
56
79
  next [] unless node.is_a?(Prism::CallNode)
57
80
  next hook_mixins(node) if hook?(node)
58
81
 
59
82
  target?(node.receiver) ? mixin_calls(node) : []
60
- end + reopened_mixins(tree)
83
+ end + base_bodies(tree).reject(&:last).flat_map(&:first).flat_map { |call| call.is_a?(Prism::CallNode) && call.receiver.nil? ? mixin_calls(call) : [] }
61
84
  end
62
85
 
63
- # `class ActiveRecord::Base; include X; end`, however the namespace is written.
64
- def reopened_mixins(tree)
65
- Introspectors::DeclaredConstant.constants(tree).flat_map do |name, node|
66
- next [] unless node.is_a?(Prism::ClassNode) && TARGETS.include?(name) && node.body
86
+ # [statements, base, hook?] for each body a base runs as self: `class ActiveRecord::Base` reopened
87
+ # however the namespace is written, `ActiveRecord::Base.class_eval do`, and an `on_load` block.
88
+ def base_bodies(tree)
89
+ reopened = Introspectors::DeclaredConstant.constants(tree).filter_map do |name, node|
90
+ [ node.body, name, false ] if node.is_a?(Prism::ClassNode) && TARGETS.include?(name)
91
+ end
92
+ evaluated = Introspectors::AstWalk.each(tree).filter_map do |node|
93
+ next unless node.is_a?(Prism::CallNode) && node.block
94
+ next [ node.block.body, TARGETS.first, true ] if hook?(node)
67
95
 
68
- node.body.body.flat_map { |call| call.is_a?(Prism::CallNode) && call.receiver.nil? ? mixin_calls(call) : [] }
96
+ [ node.block.body, node.receiver.slice.delete_prefix("::"), false ] if %i[class_eval class_exec].include?(node.name) && target?(node.receiver)
69
97
  end
98
+ (reopened + evaluated).filter_map { |body, *rest| [ body.body, *rest ] if body.is_a?(Prism::StatementsNode) }
70
99
  end
71
- private_class_method :reopened_mixins
100
+ private_class_method :base_bodies
72
101
 
73
102
  # Inside `on_load(:active_record) { ... }` the block runs in the base.
74
103
  def hook_mixins(node)
@@ -109,11 +138,12 @@ module RailsAiContext
109
138
  private_class_method :constants
110
139
 
111
140
  # Past Zeitwerk's path, a plugin's module sits in a lib file its init.rb
112
- # requires, under a name its path does not spell.
141
+ # requires, under a name its path does not spell; a namespace stub is not it.
113
142
  def declaring_file(root, name, file)
114
- ConcernPaths.find_file(root, name) ||
143
+ ConcernPaths.find_file(root, name, outer: false) ||
115
144
  ([ file ] + required_files(root, file, 2)).find do |candidate|
116
- Introspectors::DeclaredConstant.module_node(AstCache.parse(candidate).value, name)
145
+ node = Introspectors::DeclaredConstant.module_node(AstCache.parse(candidate).value, name)
146
+ node && !Introspectors::DeclaredConstant.stub?(node)
117
147
  end
118
148
  rescue StandardError => e
119
149
  RailsAiContext.debug_fail(e, nil, label: "BaseMixins.declaring_file #{name}")
@@ -113,7 +113,11 @@ module RailsAiContext
113
113
  )
114
114
  end
115
115
 
116
- guard(timeout: timeout, announce_exit: false) { require environment_rb }
116
+ result = guard(timeout: timeout, announce_exit: false) { require environment_rb }
117
+ return result unless result.booted?
118
+ return result if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
119
+
120
+ Result.new(status: :failed, error: BootError.new("config/environment.rb loaded but defined no Rails application"))
117
121
  rescue SystemExit => e
118
122
  Result.new(status: :failed, error: BootExitError.new("App called exit(#{e.status}) during boot"))
119
123
  end
@@ -40,6 +40,7 @@ module RailsAiContext
40
40
  # source: an engine keeps its dummy app under spec/dummy, so its root
41
41
  # has app/ and no config/, and it is a real target.
42
42
  def self.app_present?(root, allow_source_only: false)
43
+ return false if other_framework?(root)
43
44
  return true if File.exist?(File.join(root, "config", "environment.rb"))
44
45
  return false unless allow_source_only
45
46
 
@@ -47,6 +48,19 @@ module RailsAiContext
47
48
  Dir.glob(File.join(root, "app", "**", "*.rb")).any?
48
49
  end
49
50
 
51
+ # Sinatra MVC trees have config/environment.rb too; without application.rb the lockfile, else the Gemfile, decides.
52
+ def self.other_framework?(root)
53
+ return false if File.exist?(File.join(root, "config", "application.rb"))
54
+
55
+ require_relative "../gem_lock"
56
+ lock = GemLock.for(root)
57
+ return !lock.any?("rails", "railties") unless lock.missing?
58
+
59
+ names = lock.gemfile_gems
60
+ !names.nil? && (names & %w[rails railties]).empty?
61
+ end
62
+ private_class_method :other_framework?
63
+
50
64
  def self.call(root:, allow_static:, no_boot: false, allow_source_only: false, command: nil)
51
65
  messages = []
52
66
 
@@ -63,7 +77,7 @@ module RailsAiContext
63
77
  unless app_present?(root)
64
78
  return absent(root, messages, command: command) unless allow_static
65
79
 
66
- return enter_static("no config/environment.rb in #{root}", :source_only, root, messages)
80
+ return enter_static("no config/environment.rb in the app root", :source_only, root, messages)
67
81
  end
68
82
 
69
83
  # Bundler.setup (in config/boot.rb) strips $LOAD_PATH and the spec
@@ -166,7 +180,8 @@ module RailsAiContext
166
180
  def self.enter_static(reason, kind, root, messages)
167
181
  require_gem_without_app!
168
182
  RailsAiContext.tier = :static
169
- RailsAiContext.static_reason = reason
183
+ # Every tool response carries this in its footer, so an absolute path in a boot error goes root-relative.
184
+ RailsAiContext.static_reason = RailsAiContext::PortablePath.relativize_text(reason, root)
170
185
  RailsAiContext.static_kind = kind
171
186
  RailsAiContext.configuration.app_root = root
172
187
  Configuration.auto_load!(root)
@@ -191,7 +191,7 @@ module RailsAiContext
191
191
  def build_kwargs
192
192
  kwargs = case raw_args
193
193
  when Hash
194
- raw_args.transform_keys(&:to_sym).except(:server_context)
194
+ raw_args.transform_keys(&:to_sym).except(:server_context).transform_values { |value| utf8(value) }
195
195
  when Array
196
196
  return with_environment(parse_cli_args(raw_args).except(:server_context))
197
197
  else
@@ -219,6 +219,7 @@ module RailsAiContext
219
219
 
220
220
  # Parse ["--table", "users", "--detail", "full", "--app-only"] into { table: "users", ... }
221
221
  def parse_cli_args(args)
222
+ args = args.map { |arg| utf8(arg) }
222
223
  result = {}
223
224
  i = 0
224
225
  properties = (tool_schema[:properties] || {})
@@ -308,6 +309,19 @@ module RailsAiContext
308
309
  result
309
310
  end
310
311
 
312
+ # ARGV (here and in the rake task) carries the locale's encoding, BINARY
313
+ # under LANG=C; MCP values are always UTF-8.
314
+ def utf8(arg)
315
+ return arg unless arg.is_a?(String)
316
+ return arg if arg.encoding == Encoding::UTF_8 && arg.valid_encoding?
317
+
318
+ if [ Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII ].include?(arg.encoding)
319
+ arg.dup.force_encoding(Encoding::UTF_8).scrub
320
+ else
321
+ arg.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
322
+ end
323
+ end
324
+
311
325
  # The flag a stray word was most likely meant for: a required param
312
326
  # first, since that is the one a caller must supply.
313
327
  def suggested_flag