rails-ai-context 5.32.2 → 5.34.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 (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1491 -0
  3. data/CONTEXT.md +27 -3
  4. data/README.md +16 -12
  5. data/Rakefile +6 -0
  6. data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
  7. data/config/routes.rb +2 -3
  8. data/docs/ARCHITECTURE.md +7 -5
  9. data/docs/CLI.md +79 -6
  10. data/docs/COMPATIBILITY.md +75 -1
  11. data/docs/CONFIGURATION.md +30 -11
  12. data/docs/CUSTOM_TOOLS.md +2 -1
  13. data/docs/FAQ.md +16 -6
  14. data/docs/GUIDE.md +117 -71
  15. data/docs/INTROSPECTORS.md +10 -8
  16. data/docs/QUICKSTART.md +3 -3
  17. data/docs/RECIPES.md +1 -1
  18. data/docs/SECURITY.md +109 -25
  19. data/docs/SETUP.md +30 -6
  20. data/docs/STANDALONE.md +10 -3
  21. data/docs/TOOLS.md +152 -34
  22. data/docs/TROUBLESHOOTING.md +90 -12
  23. data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
  24. data/docs/adr/0005-workspace-mcp-entries.md +39 -0
  25. data/exe/rails-ai-context +733 -104
  26. data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
  27. data/lib/rails_ai_context/app_kind.rb +18 -5
  28. data/lib/rails_ai_context/ast_cache.rb +7 -0
  29. data/lib/rails_ai_context/base_mixins.rb +8 -8
  30. data/lib/rails_ai_context/boot_manager.rb +22 -3
  31. data/lib/rails_ai_context/change_watch.rb +14 -11
  32. data/lib/rails_ai_context/cli/app_root.rb +401 -0
  33. data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
  34. data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
  35. data/lib/rails_ai_context/code_reloader.rb +115 -1
  36. data/lib/rails_ai_context/concern_membership.rb +3 -1
  37. data/lib/rails_ai_context/concern_paths.rb +8 -3
  38. data/lib/rails_ai_context/configuration.rb +41 -9
  39. data/lib/rails_ai_context/context_file_report.rb +19 -2
  40. data/lib/rails_ai_context/database_file.rb +26 -0
  41. data/lib/rails_ai_context/doctor.rb +1122 -184
  42. data/lib/rails_ai_context/engine.rb +18 -1
  43. data/lib/rails_ai_context/erb_source.rb +19 -0
  44. data/lib/rails_ai_context/executable.rb +21 -0
  45. data/lib/rails_ai_context/facts_formatter.rb +5 -2
  46. data/lib/rails_ai_context/file_walk.rb +61 -10
  47. data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
  48. data/lib/rails_ai_context/fingerprinter.rb +40 -12
  49. data/lib/rails_ai_context/gem_lock.rb +186 -27
  50. data/lib/rails_ai_context/git_ignore.rb +22 -0
  51. data/lib/rails_ai_context/install/ai_tool.rb +31 -5
  52. data/lib/rails_ai_context/install/cleanup.rb +21 -0
  53. data/lib/rails_ai_context/install/program.rb +209 -35
  54. data/lib/rails_ai_context/install/selection_record.rb +258 -97
  55. data/lib/rails_ai_context/install/surface.rb +6 -1
  56. data/lib/rails_ai_context/install/validation_hook.rb +282 -0
  57. data/lib/rails_ai_context/install/workspace.rb +166 -0
  58. data/lib/rails_ai_context/install_mode.rb +71 -13
  59. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
  60. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
  61. data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
  63. data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
  65. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
  66. data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
  67. data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
  68. data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
  69. data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
  70. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
  71. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
  72. data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
  73. data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
  74. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
  75. data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
  76. data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
  77. data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
  78. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
  79. data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
  80. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
  81. data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
  82. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
  83. data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
  84. data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
  85. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
  86. data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
  87. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
  88. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
  90. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
  91. data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
  92. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
  93. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
  94. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
  95. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
  96. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
  97. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
  98. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
  99. data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
  100. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
  101. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
  102. data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
  103. data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
  104. data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
  105. data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
  106. data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
  107. data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
  108. data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
  109. data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
  110. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
  111. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
  112. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
  113. data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
  114. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
  115. data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
  116. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
  117. data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
  118. data/lib/rails_ai_context/live_reload.rb +20 -20
  119. data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
  120. data/lib/rails_ai_context/mcp_edge.rb +182 -32
  121. data/lib/rails_ai_context/middleware.rb +5 -1
  122. data/lib/rails_ai_context/migration_status.rb +88 -25
  123. data/lib/rails_ai_context/output_guard.rb +48 -2
  124. data/lib/rails_ai_context/package_json.rb +1 -1
  125. data/lib/rails_ai_context/path_resolver.rb +46 -13
  126. data/lib/rails_ai_context/payload.rb +92 -8
  127. data/lib/rails_ai_context/pending_migrations.rb +5 -1
  128. data/lib/rails_ai_context/portable_path.rb +3 -2
  129. data/lib/rails_ai_context/presets.rb +15 -11
  130. data/lib/rails_ai_context/redaction.rb +262 -51
  131. data/lib/rails_ai_context/resources.rb +55 -20
  132. data/lib/rails_ai_context/route_coverage.rb +8 -0
  133. data/lib/rails_ai_context/safe_file.rb +14 -1
  134. data/lib/rails_ai_context/safe_path.rb +101 -4
  135. data/lib/rails_ai_context/schema_adapter.rb +8 -0
  136. data/lib/rails_ai_context/schema_version.rb +9 -0
  137. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
  138. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
  139. data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
  140. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
  141. data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
  142. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
  143. data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
  144. data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
  145. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
  146. data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
  147. data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
  148. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
  149. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
  150. data/lib/rails_ai_context/server.rb +304 -71
  151. data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
  152. data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
  153. data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
  154. data/lib/rails_ai_context/tools/base_tool.rb +367 -46
  155. data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
  156. data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
  157. data/lib/rails_ai_context/tools/diagnose.rb +218 -11
  158. data/lib/rails_ai_context/tools/generate_test.rb +339 -39
  159. data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
  160. data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
  161. data/lib/rails_ai_context/tools/get_concern.rb +91 -27
  162. data/lib/rails_ai_context/tools/get_config.rb +13 -4
  163. data/lib/rails_ai_context/tools/get_context.rb +71 -14
  164. data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
  165. data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
  166. data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
  167. data/lib/rails_ai_context/tools/get_env.rb +18 -9
  168. data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
  169. data/lib/rails_ai_context/tools/get_gems.rb +1 -1
  170. data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
  171. data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
  172. data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
  173. data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
  174. data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
  175. data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
  176. data/lib/rails_ai_context/tools/get_routes.rb +29 -8
  177. data/lib/rails_ai_context/tools/get_schema.rb +52 -19
  178. data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
  179. data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
  180. data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
  181. data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
  182. data/lib/rails_ai_context/tools/get_view.rb +14 -14
  183. data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
  184. data/lib/rails_ai_context/tools/onboard.rb +52 -8
  185. data/lib/rails_ai_context/tools/performance_check.rb +5 -3
  186. data/lib/rails_ai_context/tools/query.rb +914 -86
  187. data/lib/rails_ai_context/tools/read_logs.rb +69 -15
  188. data/lib/rails_ai_context/tools/review_changes.rb +215 -53
  189. data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
  190. data/lib/rails_ai_context/tools/safe_call.rb +35 -14
  191. data/lib/rails_ai_context/tools/search_code.rb +243 -67
  192. data/lib/rails_ai_context/tools/search_docs.rb +4 -4
  193. data/lib/rails_ai_context/tools/security_scan.rb +143 -33
  194. data/lib/rails_ai_context/tools/session_context.rb +39 -5
  195. data/lib/rails_ai_context/tools/validate.rb +259 -40
  196. data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
  197. data/lib/rails_ai_context/version.rb +1 -1
  198. data/lib/rails_ai_context/vfs.rb +67 -40
  199. data/lib/rails_ai_context/view_file.rb +41 -3
  200. data/lib/rails_ai_context/watcher.rb +51 -13
  201. data/lib/rails_ai_context.rb +21 -0
  202. data/server.json +1 -1
  203. metadata +14 -1
@@ -17,7 +17,9 @@ module RailsAiContext
17
17
  module GemLock
18
18
  MAX_SIZE = 5 * 1024 * 1024
19
19
  SPEC_LINE = /\A {4}(\S+) \(([^)]+)\)\s*\z/
20
- DEPENDENCY_LINE = /\A {2}(\S+?)!?(?: \(.*\))?\s*\z/
20
+ SPEC_DEPENDENCY_LINE = /\A {6}(\S+)/
21
+ # Bundler puts the "!" of a gem from its own source after the requirement: "sidekiq-pro (= 8.0.3)!".
22
+ DEPENDENCY_LINE = /\A {2}(\S+?)!?(?: \(.*\))?!?\s*\z/
21
23
  # Bundler writes a non-CRuby engine after the version: "ruby 3.1.4p0 (jruby 9.4.8.0)".
22
24
  RUBY_LINE = /\A\s+ruby (\S+)(?: \((\S+) ([^)]+)\))?/
23
25
  REMOTE_LINE = /\A {2}remote: (.+?)\s*\z/
@@ -44,8 +46,9 @@ module RailsAiContext
44
46
  attr_reader :gemfile_gems
45
47
 
46
48
  def initialize(versions, ruby_versions: {}, ruby_engine: nil, reason: nil, absent: false, direct: nil, path_remotes: [],
47
- outside_gemfile: nil, no_repo: false, gemfile_gems: nil)
49
+ outside_gemfile: nil, no_repo: false, gemfile_gems: nil, sources: {})
48
50
  @versions = versions
51
+ @sources = sources
49
52
  @gemfile_gems = gemfile_gems
50
53
  @outside_gemfile = outside_gemfile
51
54
  @no_repo = no_repo
@@ -89,6 +92,11 @@ module RailsAiContext
89
92
  @versions.key?(name.to_s)
90
93
  end
91
94
 
95
+ # Where the lockfile takes a gem from: [:gem] (a rubygems source), [:path, remote] or [:git]; nil when unlocked.
96
+ def source(name)
97
+ @sources[name.to_s]
98
+ end
99
+
92
100
  def version(name)
93
101
  @versions[name.to_s]
94
102
  end
@@ -154,6 +162,18 @@ module RailsAiContext
154
162
  end
155
163
  end
156
164
 
165
+ # The lock of a Gemfile named outright, as BUNDLE_GEMFILE names a dual
166
+ # boot's Gemfile.next. Nil with no lockfile beside it.
167
+ def for_gemfile(gemfile, root)
168
+ gemfile = File.expand_path(gemfile.to_s)
169
+ dir = File.dirname(gemfile)
170
+ lockfile = File.basename(gemfile) == "gems.rb" ? File.join(dir, "gems.locked") : "#{gemfile}.lock"
171
+ return nil unless File.file?(lockfile)
172
+
173
+ parse(Bundle.new(lockfile: lockfile, gemfile: gemfile, lock_label: File.basename(lockfile), gemfile_label: File.basename(gemfile),
174
+ dir: dir, trusted: dir, outside: nil, no_repo: false), root.to_s)
175
+ end
176
+
157
177
  # The app's own Gemfile and lockfile, or, with no lockfile of its own, the
158
178
  # bundle config/boot.rb points Bundler at (an engine's test/dummy).
159
179
  def bundle(root)
@@ -187,14 +207,20 @@ module RailsAiContext
187
207
  end
188
208
  private_class_method :boot_bundle
189
209
 
190
- # The BUNDLE_GEMFILE config/boot.rb sets, when it is outside the app root.
210
+ # The BUNDLE_GEMFILE config/boot.rb sets, when it is outside the app root:
211
+ # the Gemfile the app boots against when nothing else names one, which a
212
+ # caller may name whether or not it is in a repository this reads.
191
213
  def boot_gemfile(root)
192
214
  real_root = File.realpath(root)
193
215
  real = File.realpath(File.join(real_root, "config/boot.rb"))
194
216
  return nil unless SafePath.contained?(real, real_root)
195
217
 
196
- result = ruby_parse(real) or return nil
197
- relative, anchor = Introspectors::AstWalk.each(result.value).lazy.filter_map { |node| bundle_gemfile_path(node) }.first
218
+ relative, anchor = if loaded?
219
+ result = ruby_parse(real) or return nil
220
+ Introspectors::AstWalk.each(result.value).lazy.filter_map { |node| bundle_gemfile_path(node) }.first
221
+ else
222
+ Preboot.bundle_gemfile_path(Preboot.tree(real))
223
+ end
198
224
  return nil unless relative
199
225
 
200
226
  # Relative to __FILE__ the path starts from boot.rb itself, one level below __dir__.
@@ -203,7 +229,6 @@ module RailsAiContext
203
229
  rescue SystemCallError
204
230
  nil
205
231
  end
206
- private_class_method :boot_gemfile
207
232
 
208
233
  # `ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)`, the line `rails new`
209
234
  # and `rails plugin new` write, with `=` or `__FILE__` as older templates do.
@@ -272,13 +297,18 @@ module RailsAiContext
272
297
  in_specs = false
273
298
  in_dependencies = false
274
299
  in_path = false
300
+ in_git = false
275
301
  path_remotes = []
302
+ path_dependencies = {}
303
+ spec_name = nil
304
+ sources = {}
276
305
  specs_section = false
277
306
  content.each_line do |line|
278
307
  if line.match?(/\A\S/)
279
308
  in_specs = false
280
309
  in_dependencies = line.start_with?("DEPENDENCIES")
281
310
  in_path = line.strip == "PATH"
311
+ in_git = line.strip == "GIT"
282
312
  elsif in_path && (match = line.match(REMOTE_LINE))
283
313
  path_remotes << match[1].strip
284
314
  elsif in_dependencies && (match = line.match(DEPENDENCY_LINE))
@@ -291,6 +321,10 @@ module RailsAiContext
291
321
  # per platform. The text before the first hyphen is the version; a
292
322
  # prerelease tag ("1.70.0-beta1") is dropped along with the platform.
293
323
  versions[match[1]] ||= match[2].split("-", 2).first
324
+ spec_name = match[1]
325
+ sources[match[1]] ||= if in_path then [ :path, path_remotes.last ] elsif in_git then [ :git ] else [ :gem ] end
326
+ elsif in_specs && in_path && spec_name && (match = line.match(SPEC_DEPENDENCY_LINE))
327
+ (path_dependencies[spec_name] ||= []) << match[1]
294
328
  elsif (match = line.match(RUBY_LINE)) && match[1].match?(PLAIN_VERSION)
295
329
  ruby_version = [ match[1], engine_name(match[2], match[3]) ]
296
330
  end
@@ -300,8 +334,11 @@ module RailsAiContext
300
334
  # and answering it as an app with no gems denies every gem it holds.
301
335
  return Spec.new({}, reason: "#{File.basename(path)} has no specs section") unless specs_section
302
336
 
337
+ # A gemspec or `path:` gem the Gemfile names brings its runtime dependencies in as the Gemfile's own, as Bundler resolves them.
338
+ path_dependencies.each { |name, deps| direct.merge(deps) if direct.include?(name) }
303
339
  facts = gemfile(bundle)
304
- Spec.new(versions, **declared_ruby(ruby_version, root, bundle, facts), direct: direct, path_remotes: path_remotes)
340
+ Spec.new(versions, **declared_ruby(ruby_version, root, bundle, facts), direct: direct, path_remotes: path_remotes,
341
+ sources: sources)
305
342
  end
306
343
  private_class_method :parse
307
344
 
@@ -320,17 +357,14 @@ module RailsAiContext
320
357
  def gemfile_entries(bundle)
321
358
  return Introspectors::GemfileGems.read_bundle(bundle) if loaded?
322
359
 
323
- result = ruby_parse(bundle.gemfile) or return nil
324
- Introspectors::AstWalk.each(result.value).filter_map do |node|
325
- next unless node.is_a?(Prism::CallNode) && node.receiver.nil?
326
-
327
- args = node.arguments&.arguments || []
328
- case node.name
329
- when :ruby
330
- options = args.grep(Prism::KeywordHashNode).flat_map(&:elements).grep(Prism::AssocNode).to_h { |pair| [ literal(pair.key), literal(pair.value) ] }
331
- { type: :ruby, version: literal(args.first), engine: options["engine"], engine_version: options["engine_version"] }
332
- when :gem then args.first.is_a?(Prism::StringNode) ? { type: :gem, name: args.first.unescaped } : { type: :unknown_gems }
333
- when :gemspec, :eval_gemfile then { type: :unknown_gems }
360
+ tree = Preboot.tree(bundle.gemfile) or return nil
361
+ Preboot.calls(tree).filter_map do |name, args|
362
+ case name
363
+ when "ruby"
364
+ options = Preboot.options(args)
365
+ { type: :ruby, version: Preboot.literal(args.first), engine: options["engine"], engine_version: options["engine_version"] }
366
+ when "gem" then (gem = Preboot.string(args.first)) ? { type: :gem, name: gem } : { type: :unknown_gems }
367
+ when "gemspec", "eval_gemfile" then { type: :unknown_gems }
334
368
  end
335
369
  end
336
370
  end
@@ -341,21 +375,146 @@ module RailsAiContext
341
375
  end
342
376
  private_class_method :loaded?
343
377
 
344
- # AstCache once the gem is loaded; before the boot Prism alone, as AstCache's
345
- # concurrent-ruby would load ahead of the app's bundle.
378
+ # Once the gem is loaded; before that, Preboot reads the file.
346
379
  def ruby_parse(path)
347
- return nil unless path
348
- return AstCache.parse(File.realpath(path)) if loaded?
380
+ return nil unless path && loaded?
349
381
 
350
- require "prism"
351
- require_relative "introspectors/ast_walk"
352
- content = SafeFile.read(path, max_size: MAX_SIZE)
353
- content && Prism.parse(content)
354
- rescue SystemCallError, ArgumentError, LoadError
382
+ AstCache.parse(File.realpath(path))
383
+ rescue SystemCallError, ArgumentError
355
384
  nil
356
385
  end
357
386
  private_class_method :ruby_parse
358
387
 
388
+ # Before the boot the Gemfile and config/boot.rb are read with Ripper,
389
+ # which ships inside Ruby. Prism is a gem the app's bundle locks (every
390
+ # Rails 8 app's does), so ours, loaded first, had the app's copy load over
391
+ # it at the boot: "already initialized constant", then a mixed parser.
392
+ # AstCache would bring concurrent-ruby in ahead of the bundle the same way.
393
+ module Preboot
394
+ module_function
395
+
396
+ def tree(path)
397
+ return nil unless path
398
+
399
+ require "ripper"
400
+ content = SafeFile.read(path, max_size: MAX_SIZE)
401
+ content && Ripper.sexp(content)
402
+ rescue SystemCallError, ArgumentError, LoadError
403
+ nil
404
+ end
405
+
406
+ # Every node, parent first: a Ripper node is an Array led by its type.
407
+ def each(node, &block)
408
+ return unless node.is_a?(Array)
409
+
410
+ yield node if node.first.is_a?(Symbol)
411
+ node.each { |child| each(child, &block) }
412
+ end
413
+
414
+ # [name, arguments] of each call with no receiver: `gem "x"`, `gem("x")`, a bare `gemspec`.
415
+ def calls(tree)
416
+ found = []
417
+ each(tree) do |node|
418
+ case node.first
419
+ when :command then found << [ ident(node[1]), arguments(node[2]) ]
420
+ when :method_add_arg then found << [ ident(node[1][1]), arguments(node[2]) ] if node[1].is_a?(Array) && node[1].first == :fcall
421
+ when :vcall then found << [ ident(node[1]), [] ]
422
+ end
423
+ end
424
+ found
425
+ end
426
+
427
+ def ident(node)
428
+ node[1] if node.is_a?(Array) && node.first == :@ident
429
+ end
430
+
431
+ def arguments(node)
432
+ return [] unless node.is_a?(Array)
433
+
434
+ case node.first
435
+ when :arg_paren then arguments(node[1])
436
+ when :args_add_block then node[1].is_a?(Array) ? node[1] : []
437
+ else []
438
+ end
439
+ end
440
+
441
+ # A string as written, nil for anything computed. Escapes stay as
442
+ # written: no gem name, version or path the Gemfile needs holds one.
443
+ def string(node)
444
+ return nil unless node.is_a?(Array) && node.first == :string_literal
445
+
446
+ parts = node[1].is_a?(Array) ? node[1].drop(1) : []
447
+ return "" if parts.empty?
448
+
449
+ parts.first[1] if parts.one? && parts.first.is_a?(Array) && parts.first.first == :@tstring_content
450
+ end
451
+
452
+ def literal(node)
453
+ return string(node) unless node.is_a?(Array) && node.first == :symbol_literal
454
+
455
+ symbol = node[1]
456
+ symbol[1][1] if symbol.is_a?(Array) && symbol.first == :symbol && symbol[1].is_a?(Array)
457
+ end
458
+
459
+ # `engine: "jruby"`, `"engine" => "jruby"` and `:engine => "jruby"` alike.
460
+ def options(args)
461
+ args.select { |arg| arg.is_a?(Array) && arg.first == :bare_assoc_hash }.flat_map { |hash| Array(hash[1]) }
462
+ .select { |pair| pair.is_a?(Array) && pair.first == :assoc_new }
463
+ .to_h { |pair| [ key(pair[1]), literal(pair[2]) ] }
464
+ end
465
+
466
+ def key(node)
467
+ node.is_a?(Array) && node.first == :@label ? node[1].delete_suffix(":") : literal(node)
468
+ end
469
+
470
+ # [path, :file or :dir] from config/boot.rb's
471
+ # `ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)`, `=` and `__FILE__` alike.
472
+ def bundle_gemfile_path(tree)
473
+ each(tree) do |node|
474
+ value = case node.first
475
+ when :opassign then node[3] if env_field?(node[1]) && node[2].is_a?(Array) && node[2][1] == "||="
476
+ when :assign then node[2] if env_field?(node[1])
477
+ end
478
+ found = value && expand_path(value)
479
+ return found if found
480
+ end
481
+ nil
482
+ end
483
+
484
+ def env_field?(node)
485
+ return false unless node.is_a?(Array) && node.first == :aref_field
486
+
487
+ receiver = node[1]
488
+ args = arguments(node[2])
489
+ receiver.is_a?(Array) && receiver.first == :var_ref && receiver[1].is_a?(Array) && receiver[1][1] == "ENV" &&
490
+ args.one? && string(args.first) == "BUNDLE_GEMFILE"
491
+ end
492
+
493
+ def expand_path(node)
494
+ return nil unless node.is_a?(Array) && node.first == :method_add_arg
495
+
496
+ call = node[1]
497
+ return nil unless call.is_a?(Array) && call.first == :call && ident(call[3]) == "expand_path" &&
498
+ call[1].is_a?(Array) && call[1].first == :var_ref && call[1][1].is_a?(Array) && call[1][1][1] == "File"
499
+
500
+ path, anchor = args = arguments(node[2])
501
+ relative = string(path)
502
+ return nil unless relative && args.size == 2
503
+
504
+ if anchor.is_a?(Array) && anchor.first == :var_ref && anchor[1].is_a?(Array) && anchor[1][1] == "__FILE__" then [ relative, :file ]
505
+ elsif dir_call?(anchor) then [ relative, :dir ]
506
+ end
507
+ end
508
+
509
+ def dir_call?(node)
510
+ return false unless node.is_a?(Array)
511
+
512
+ (node.first == :vcall && ident(node[1]) == "__dir__") ||
513
+ (node.first == :method_add_arg && node[1].is_a?(Array) && node[1].first == :fcall && ident(node[1][1]) == "__dir__")
514
+ end
515
+ end
516
+ private_constant :Preboot
517
+
359
518
  # A requirement such as `ruby ">= 3.3.0"` names a range, not a version, so it is left unanswered.
360
519
  def gemfile_ruby(entry)
361
520
  version = entry[:version]
@@ -101,6 +101,28 @@ module RailsAiContext
101
101
  nil
102
102
  end
103
103
 
104
+ # The directory links ripgrep would meet under `start`, in its order, past
105
+ # the same ignore files and `skip`; it enters none of them.
106
+ def dir_links(start = @root, skip: nil)
107
+ Dir.children(start).sort.flat_map do |name|
108
+ next [] if name.start_with?(".")
109
+
110
+ path = File.join(start, name)
111
+ stat = File.lstat(path)
112
+ next [] unless stat.symlink? || stat.directory?
113
+
114
+ relative = path.delete_prefix("#{@root}/")
115
+ next [] if skip&.call(relative, true) || verdict(path, true) == :ignore
116
+ next dir_links(path, skip: skip) unless stat.symlink?
117
+
118
+ File.directory?(path) ? [ path ] : []
119
+ rescue SystemCallError
120
+ []
121
+ end
122
+ rescue SystemCallError
123
+ []
124
+ end
125
+
104
126
  private
105
127
 
106
128
  def dir_ignored?(path)
@@ -16,6 +16,17 @@ module RailsAiContext
16
16
  #
17
17
  # rule_files names what the gem writes inside rules_dir. The directory is shared with
18
18
  # rules the user writes by hand, so cleanup removes these names and nothing else.
19
+ #
20
+ # mcp_config[:folder_variable] is how the tool's config names the folder it sits in, where
21
+ # it has a name for it: a workspace entry's app path starts from that name. The others get
22
+ # a bare relative path, which they resolve against the folder they were started in.
23
+ #
24
+ # mcp_config[:client] is the program that reads the config, and [:jsonc] whether it reads
25
+ # JSONC - comments and trailing commas - or refuses them, as JSON does. VS Code reads
26
+ # .vscode/mcp.json as a standalone configuration file, with its JSONC parser and trailing
27
+ # commas allowed; OpenCode reads opencode.json with jsonc-parser, trailing commas allowed.
28
+ # Claude Code reads .mcp.json with JSON.parse. Cursor's is held to JSON as well, the form
29
+ # every reader takes: nothing shows its reader takes JSONC.
19
30
  AiTool = Struct.new(:number, :key, :name, :files, :context_paths, :rules_dir, :rule_files, :mcp_config,
20
31
  :legacy_paths, :owned_dir, keyword_init: true)
21
32
 
@@ -27,7 +38,7 @@ module RailsAiContext
27
38
  context_paths: %w[CLAUDE.md .claude/rules],
28
39
  rules_dir: ".claude/rules",
29
40
  rule_files: %w[rails-context.md rails-schema.md rails-models.md rails-mcp-tools.md rails-components.md],
30
- mcp_config: { path: ".mcp.json", root_key: "mcpServers", format: :mcp_json },
41
+ mcp_config: { path: ".mcp.json", root_key: "mcpServers", format: :mcp_json, client: "Claude Code", jsonc: false },
31
42
  legacy_paths: [ ".claude/rules/rails-ui-patterns.md", ".claude/rules/rails-accessibility.md" ],
32
43
  owned_dir: ".claude"
33
44
  ),
@@ -37,7 +48,8 @@ module RailsAiContext
37
48
  context_paths: %w[.cursor/rules .cursorrules],
38
49
  rules_dir: ".cursor/rules",
39
50
  rule_files: %w[rails-project.mdc rails-models.mdc rails-controllers.mdc rails-mcp-tools.mdc],
40
- mcp_config: { path: ".cursor/mcp.json", root_key: "mcpServers", format: :mcp_json },
51
+ mcp_config: { path: ".cursor/mcp.json", root_key: "mcpServers", format: :mcp_json,
52
+ folder_variable: "${workspaceFolder}", client: "Cursor", jsonc: false },
41
53
  legacy_paths: [ ".cursor/rules/rails-ui-patterns.mdc" ],
42
54
  owned_dir: ".cursor"
43
55
  ),
@@ -50,26 +62,34 @@ module RailsAiContext
50
62
  rails-context.instructions.md rails-models.instructions.md
51
63
  rails-controllers.instructions.md rails-mcp-tools.instructions.md
52
64
  ],
53
- mcp_config: { path: ".vscode/mcp.json", root_key: "servers", format: :vscode_json },
65
+ mcp_config: { path: ".vscode/mcp.json", root_key: "servers", format: :vscode_json,
66
+ folder_variable: "${workspaceFolder}", client: "VS Code", jsonc: true },
54
67
  legacy_paths: [ ".github/instructions/rails-ui-patterns.instructions.md" ]
55
68
  ),
56
69
  new(
57
70
  number: "4", key: :opencode, name: "OpenCode",
58
71
  files: "AGENTS.md",
59
72
  context_paths: %w[AGENTS.md app/models/AGENTS.md app/controllers/AGENTS.md],
60
- mcp_config: { path: "opencode.json", root_key: "mcp", format: :opencode_json },
73
+ mcp_config: { path: "opencode.json", root_key: "mcp", format: :opencode_json, client: "OpenCode", jsonc: true },
61
74
  legacy_paths: []
62
75
  ),
63
76
  new(
64
77
  number: "5", key: :codex, name: "Codex CLI",
65
78
  files: "AGENTS.md + .codex/config.toml",
66
79
  context_paths: %w[AGENTS.md app/models/AGENTS.md app/controllers/AGENTS.md],
67
- mcp_config: { path: ".codex/config.toml", root_key: nil, format: :codex_toml },
80
+ mcp_config: { path: ".codex/config.toml", root_key: nil, format: :codex_toml, client: "Codex CLI" },
68
81
  legacy_paths: [],
69
82
  owned_dir: ".codex"
70
83
  )
71
84
  ].freeze
72
85
 
86
+ # What a summary lists for the tool. `files` names Codex CLI's MCP
87
+ # config beside its context files, and a CLI-mode or per-app summary
88
+ # writes no MCP config, so it leaves that part out.
89
+ def files_label(mcp: true)
90
+ mcp ? files : context_files
91
+ end
92
+
73
93
  class << self
74
94
  def all
75
95
  ALL
@@ -88,6 +108,12 @@ module RailsAiContext
88
108
  ALL.flat_map { |tool| tool.legacy_paths.map { |path| { path: path, ai_tool: tool.key } } }
89
109
  end
90
110
  end
111
+
112
+ # What `rails ai:context:<key>` writes for the tool: its files without
113
+ # its MCP config, which only the install writes.
114
+ def context_files
115
+ files.split(" + ").reject { |file| file == mcp_config[:path] }.join(" + ")
116
+ end
91
117
  end
92
118
  end
93
119
  end
@@ -49,9 +49,30 @@ module RailsAiContext
49
49
  end
50
50
  end
51
51
 
52
+ prune_emptied(outcome, root)
52
53
  outcome
53
54
  end
54
55
 
56
+ # A tool's dot-directory (.cursor/, .github/) that the removal emptied
57
+ # goes too, and is reported like any other path. One that holds
58
+ # anything else stays, and a directory outside the dot-directories -
59
+ # app/models, emptied of its AGENTS.md - is never removed.
60
+ def prune_emptied(outcome, root)
61
+ outcome[:removed].map { |path| File.dirname(path.delete_suffix("/")) }.uniq.each do |dir|
62
+ while dir.start_with?(".") && dir != "."
63
+ full = File.join(root.to_s, dir)
64
+ break unless File.directory?(full) && !File.symlink?(full) && Dir.empty?(full)
65
+
66
+ Dir.rmdir(full)
67
+ outcome[:removed] << "#{dir}/"
68
+ dir = File.dirname(dir)
69
+ end
70
+ rescue SystemCallError
71
+ next
72
+ end
73
+ end
74
+ private_class_method :prune_emptied
75
+
55
76
  # A symlink is somebody's own arrangement, and writing through one edits
56
77
  # a file this run was not asked about.
57
78
  def remove_rule_files(tool, dir, root, outcome)