rails-ai-context 5.26.0 → 5.28.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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +329 -0
  3. data/README.md +3 -1
  4. data/docs/ARCHITECTURE.md +2 -2
  5. data/docs/COMPATIBILITY.md +4 -1
  6. data/docs/CONFIGURATION.md +27 -0
  7. data/docs/FAQ.md +13 -0
  8. data/docs/INTROSPECTORS.md +1 -1
  9. data/docs/QUICKSTART.md +5 -1
  10. data/docs/SETUP.md +4 -0
  11. data/docs/STANDALONE.md +3 -0
  12. data/docs/TOOLS.md +12 -6
  13. data/exe/rails-ai-context +59 -20
  14. data/lib/generators/rails_ai_context/install/install_generator.rb +54 -11
  15. data/lib/rails_ai_context/action_filters.rb +43 -7
  16. data/lib/rails_ai_context/boot_manager.rb +23 -2
  17. data/lib/rails_ai_context/cli/entry_boot.rb +6 -0
  18. data/lib/rails_ai_context/cli/tool_runner.rb +10 -22
  19. data/lib/rails_ai_context/concern_macros.rb +1 -2
  20. data/lib/rails_ai_context/concern_paths.rb +6 -1
  21. data/lib/rails_ai_context/configuration.rb +8 -1
  22. data/lib/rails_ai_context/detail_level.rb +7 -4
  23. data/lib/rails_ai_context/doctor.rb +5 -2
  24. data/lib/rails_ai_context/erb_source.rb +45 -0
  25. data/lib/rails_ai_context/fingerprinter.rb +1 -2
  26. data/lib/rails_ai_context/gem_lock.rb +18 -2
  27. data/lib/rails_ai_context/hydrators/controller_hydrator.rb +3 -6
  28. data/lib/rails_ai_context/hydrators/model_hints.rb +5 -2
  29. data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +1 -1
  30. data/lib/rails_ai_context/hydrators/view_hydrator.rb +1 -2
  31. data/lib/rails_ai_context/install/cleanup.rb +23 -7
  32. data/lib/rails_ai_context/install/program.rb +37 -13
  33. data/lib/rails_ai_context/install/selection_record.rb +72 -0
  34. data/lib/rails_ai_context/install_mode.rb +1 -2
  35. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -2
  36. data/lib/rails_ai_context/introspectors/action_resolver.rb +45 -13
  37. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -4
  38. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +4 -8
  39. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +17 -16
  40. data/lib/rails_ai_context/introspectors/api_introspector.rb +92 -21
  41. data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +2 -8
  42. data/lib/rails_ai_context/introspectors/auth_introspector.rb +11 -22
  43. data/lib/rails_ai_context/introspectors/autoload_introspector.rb +6 -12
  44. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -15
  45. data/lib/rails_ai_context/introspectors/config_introspector.rb +9 -64
  46. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +5 -10
  47. data/lib/rails_ai_context/introspectors/controller_filters.rb +1 -2
  48. data/lib/rails_ai_context/introspectors/controller_introspector.rb +23 -114
  49. data/lib/rails_ai_context/introspectors/convention_introspector.rb +21 -49
  50. data/lib/rails_ai_context/introspectors/credentials_introspector.rb +3 -6
  51. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +3 -6
  52. data/lib/rails_ai_context/introspectors/declared_constant.rb +2 -4
  53. data/lib/rails_ai_context/introspectors/devops_introspector.rb +35 -56
  54. data/lib/rails_ai_context/introspectors/eager_load.rb +1 -2
  55. data/lib/rails_ai_context/introspectors/engine_introspector.rb +2 -4
  56. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +48 -11
  57. data/lib/rails_ai_context/introspectors/env_introspector.rb +2 -4
  58. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +12 -31
  59. data/lib/rails_ai_context/introspectors/gem_introspector.rb +60 -8
  60. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +48 -48
  61. data/lib/rails_ai_context/introspectors/initializer_introspector.rb +4 -8
  62. data/lib/rails_ai_context/introspectors/job_introspector.rb +89 -18
  63. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +3 -8
  64. data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +2 -2
  65. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +36 -0
  66. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +22 -4
  67. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +3 -6
  68. data/lib/rails_ai_context/introspectors/migration_introspector.rb +2 -4
  69. data/lib/rails_ai_context/introspectors/migration_replay.rb +21 -36
  70. data/lib/rails_ai_context/introspectors/model_introspector.rb +58 -30
  71. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +5 -10
  72. data/lib/rails_ai_context/introspectors/observability_introspector.rb +6 -12
  73. data/lib/rails_ai_context/introspectors/performance_introspector.rb +73 -30
  74. data/lib/rails_ai_context/introspectors/route_introspector.rb +37 -30
  75. data/lib/rails_ai_context/introspectors/schema_introspector.rb +6 -12
  76. data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -2
  77. data/lib/rails_ai_context/introspectors/security_introspector.rb +9 -18
  78. data/lib/rails_ai_context/introspectors/source_introspector.rb +1 -2
  79. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +6 -51
  80. data/lib/rails_ai_context/introspectors/table_name.rb +1 -2
  81. data/lib/rails_ai_context/introspectors/test_introspector.rb +20 -46
  82. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +19 -51
  83. data/lib/rails_ai_context/introspectors/view_introspector.rb +6 -10
  84. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +19 -10
  85. data/lib/rails_ai_context/legacy_cleanup.rb +3 -1
  86. data/lib/rails_ai_context/mcp_config_generator.rb +42 -67
  87. data/lib/rails_ai_context/migration_status.rb +1 -2
  88. data/lib/rails_ai_context/output_guard.rb +43 -3
  89. data/lib/rails_ai_context/package_json.rb +98 -0
  90. data/lib/rails_ai_context/payload.rb +45 -0
  91. data/lib/rails_ai_context/portable_path.rb +1 -2
  92. data/lib/rails_ai_context/redaction.rb +26 -10
  93. data/lib/rails_ai_context/schema_adapter.rb +2 -2
  94. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +6 -34
  95. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +1 -37
  96. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +1 -40
  97. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +41 -0
  98. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -1
  99. data/lib/rails_ai_context/tasks/rails_ai_context.rake +37 -16
  100. data/lib/rails_ai_context/tools/analyze_feature.rb +45 -73
  101. data/lib/rails_ai_context/tools/base_tool.rb +41 -35
  102. data/lib/rails_ai_context/tools/dependency_graph.rb +96 -15
  103. data/lib/rails_ai_context/tools/diagnose.rb +2 -4
  104. data/lib/rails_ai_context/tools/generate_test.rb +144 -54
  105. data/lib/rails_ai_context/tools/get_active_support.rb +5 -1
  106. data/lib/rails_ai_context/tools/get_api.rb +25 -6
  107. data/lib/rails_ai_context/tools/get_callbacks.rb +7 -11
  108. data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -5
  109. data/lib/rails_ai_context/tools/get_concern.rb +43 -57
  110. data/lib/rails_ai_context/tools/get_context.rb +2 -1
  111. data/lib/rails_ai_context/tools/get_controllers.rb +54 -34
  112. data/lib/rails_ai_context/tools/get_conventions.rb +25 -29
  113. data/lib/rails_ai_context/tools/get_env.rb +99 -116
  114. data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -5
  115. data/lib/rails_ai_context/tools/get_gems.rb +9 -2
  116. data/lib/rails_ai_context/tools/get_helper_methods.rb +58 -51
  117. data/lib/rails_ai_context/tools/get_job_pattern.rb +29 -18
  118. data/lib/rails_ai_context/tools/get_model_details.rb +10 -25
  119. data/lib/rails_ai_context/tools/get_partial_interface.rb +21 -40
  120. data/lib/rails_ai_context/tools/get_routes.rb +7 -7
  121. data/lib/rails_ai_context/tools/get_schema.rb +3 -9
  122. data/lib/rails_ai_context/tools/get_service_pattern.rb +134 -51
  123. data/lib/rails_ai_context/tools/get_stimulus.rb +10 -13
  124. data/lib/rails_ai_context/tools/get_test_info.rb +3 -9
  125. data/lib/rails_ai_context/tools/get_turbo_map.rb +4 -11
  126. data/lib/rails_ai_context/tools/get_view.rb +12 -10
  127. data/lib/rails_ai_context/tools/migration_advisor.rb +2 -4
  128. data/lib/rails_ai_context/tools/onboard.rb +6 -223
  129. data/lib/rails_ai_context/tools/performance_check.rb +28 -35
  130. data/lib/rails_ai_context/tools/query.rb +6 -1
  131. data/lib/rails_ai_context/tools/runtime_info.rb +4 -11
  132. data/lib/rails_ai_context/tools/search_code.rb +41 -21
  133. data/lib/rails_ai_context/tools/search_docs.rb +1 -2
  134. data/lib/rails_ai_context/tools/section_fetch.rb +1 -2
  135. data/lib/rails_ai_context/tools/security_scan.rb +1 -5
  136. data/lib/rails_ai_context/tools/session_context.rb +1 -2
  137. data/lib/rails_ai_context/tools/validate.rb +1 -2
  138. data/lib/rails_ai_context/tools/validate_semantics.rb +42 -17
  139. data/lib/rails_ai_context/version.rb +1 -1
  140. data/lib/rails_ai_context/vfs.rb +9 -18
  141. data/lib/rails_ai_context/view_file.rb +38 -0
  142. data/lib/rails_ai_context/watcher.rb +4 -1
  143. data/lib/rails_ai_context.rb +22 -1
  144. data/server.json +1 -1
  145. metadata +3 -2
  146. data/lib/rails_ai_context/serializers/section_guard.rb +0 -15
data/exe/rails-ai-context CHANGED
@@ -191,6 +191,16 @@ class RailsAiContextCLI < Thor
191
191
  end
192
192
  ::RailsAiContext::LegacyCleanup.prompt_legacy_files(cleanup_tools, root: Dir.pwd)
193
193
 
194
+ # An explicit --format names a file, so it is written either way. With
195
+ # no format and an MCP-only install there is nothing to write, and the
196
+ # rake surface says the same thing in the same words.
197
+ if options[:format].nil? && !::RailsAiContext.configuration.context_files
198
+ $stderr.puts "MCP-only install (config.context_files = false): no context files written."
199
+ $stderr.puts "Run `rails-ai-context context --format claude` to write one anyway, " \
200
+ "or set config.context_files = true."
201
+ return
202
+ end
203
+
194
204
  $stderr.puts "Introspecting Rails app..."
195
205
 
196
206
  # An explicit --format overrides the recorded selection.
@@ -264,6 +274,8 @@ class RailsAiContextCLI < Thor
264
274
 
265
275
  desc "init", "Set up rails-ai-context for standalone use (creates .rails-ai-context.yml and .mcp.json)"
266
276
  option :no_boot, type: :boolean, default: false, desc: "Skip booting the app; serve static analysis only"
277
+ option :mcp_only, type: :boolean, default: false,
278
+ desc: "Write the MCP config and nothing else: no CLAUDE.md, AGENTS.md, rules files or .ai-context.json"
267
279
  def init
268
280
  unless entry_boot.app_present?(Dir.pwd, allow_source_only: true)
269
281
  $stderr.puts "Error: No Rails app found in #{Dir.pwd}"
@@ -278,7 +290,9 @@ class RailsAiContextCLI < Thor
278
290
 
279
291
  # --- Prompts (no Rails needed) ---
280
292
  ai_tools = prompt_ai_tools
281
- tool_mode = prompt_tool_mode
293
+ setup = prompt_setup
294
+ tool_mode = setup.tool_mode
295
+ context_files = setup.context_files
282
296
 
283
297
  # --- Cleanup removed tools ---
284
298
  cleanup_removed_tools(previous_tools, ai_tools) if previous_tools&.any?
@@ -287,13 +301,13 @@ class RailsAiContextCLI < Thor
287
301
  ::RailsAiContext::LegacyCleanup.prompt_legacy_files(ai_tools, root: Dir.pwd)
288
302
 
289
303
  # --- Record the selection (YAML, plus the initializer line) ---
290
- record_selection(ai_tools, tool_mode)
304
+ record_selection(ai_tools, tool_mode, context_files)
291
305
 
292
306
  # --- Write per-tool MCP config files (MCP mode only) ---
293
307
  write_mcp_configs(ai_tools, tool_mode)
294
308
 
295
309
  # --- Add .ai-context.json to .gitignore ---
296
- add_to_gitignore
310
+ add_to_gitignore(context_files)
297
311
 
298
312
  # --- Boot Rails, load config, generate context ---
299
313
  # The config files are already on disk by now, so failing here leaves the
@@ -301,12 +315,17 @@ class RailsAiContextCLI < Thor
301
315
  # what you run on a repo you have just cloned.
302
316
  boot_rails!(command: "init", allow_static: true)
303
317
 
304
- $stderr.puts ""
305
- $stderr.puts "Generating AI context files..."
306
- print_context_result(::RailsAiContext.generate_context(format: ai_tools))
318
+ if context_files
319
+ $stderr.puts ""
320
+ $stderr.puts "Generating AI context files..."
321
+ print_context_result(::RailsAiContext.generate_context(format: ai_tools))
322
+ else
323
+ $stderr.puts ""
324
+ $stderr.puts "MCP-only setup: no context files written."
325
+ end
307
326
 
308
327
  # --- Instructions ---
309
- show_standalone_instructions(ai_tools, tool_mode)
328
+ show_standalone_instructions(ai_tools, tool_mode, context_files)
310
329
  end
311
330
 
312
331
  desc "preset NAME", "Run a multi-tool preset (architecture, debugging, migration)"
@@ -367,8 +386,12 @@ class RailsAiContextCLI < Thor
367
386
  ::RailsAiContext::Install::Program.select_ai_tools(program_surface)
368
387
  end
369
388
 
370
- def prompt_tool_mode
371
- ::RailsAiContext::Install::Program.select_tool_mode(program_surface)
389
+ def prompt_setup
390
+ if options[:mcp_only]
391
+ return ::RailsAiContext::Install::Program::Setup.new(:mcp, false)
392
+ end
393
+
394
+ ::RailsAiContext::Install::Program.select_setup(program_surface)
372
395
  end
373
396
 
374
397
  def read_previous_ai_tools
@@ -381,14 +404,17 @@ class RailsAiContextCLI < Thor
381
404
  )
382
405
  end
383
406
 
384
- def add_to_gitignore
385
- ::RailsAiContext::Install::Program.mark_gitignore(program_surface, root: Dir.pwd)
407
+ def add_to_gitignore(context_files = true)
408
+ ::RailsAiContext::Install::Program.mark_gitignore(program_surface, root: Dir.pwd,
409
+ context_files: context_files)
386
410
  end
387
411
 
388
- def record_selection(ai_tools, tool_mode)
412
+ def record_selection(ai_tools, tool_mode, context_files = true)
389
413
  result = ::RailsAiContext::Install::SelectionRecord.write(
390
- ai_tools, root: Dir.pwd, extra_yaml: { "tool_mode" => tool_mode.to_s }
414
+ ai_tools, root: Dir.pwd,
415
+ extra_yaml: { "tool_mode" => tool_mode.to_s, "context_files" => context_files }
391
416
  )
417
+ ::RailsAiContext::Install::SelectionRecord.write_context_files(context_files, root: Dir.pwd)
392
418
 
393
419
  ::RailsAiContext::Install::SelectionRecord.messages(result).each do |level, text|
394
420
  $stderr.puts(level == :warn ? "Warning: #{text}" : text)
@@ -402,7 +428,7 @@ class RailsAiContextCLI < Thor
402
428
  )
403
429
  end
404
430
 
405
- def show_standalone_instructions(ai_tools, tool_mode)
431
+ def show_standalone_instructions(ai_tools, tool_mode, context_files = true)
406
432
  $stderr.puts ""
407
433
  $stderr.puts "=" * 50
408
434
  $stderr.puts " rails-ai-context initialized!"
@@ -411,11 +437,15 @@ class RailsAiContextCLI < Thor
411
437
  $stderr.puts "Your setup:"
412
438
  ::RailsAiContext::Install::AiTool.all.each do |tool|
413
439
  next unless ai_tools.include?(tool.key)
414
- $stderr.puts " #{tool.name.ljust(16)} -> #{tool.files}"
440
+ $stderr.puts " #{tool.name.ljust(16)} -> #{context_files ? tool.files : 'MCP config only'}"
441
+ end
442
+ unless context_files
443
+ $stderr.puts ""
444
+ $stderr.puts " Left alone on purpose: CLAUDE.md, AGENTS.md, the rules directories and .ai-context.json."
415
445
  end
416
446
  $stderr.puts ""
417
447
  $stderr.puts "Commands:"
418
- $stderr.puts " rails-ai-context context # Regenerate context files"
448
+ $stderr.puts " rails-ai-context context # Regenerate context files" if context_files
419
449
  $stderr.puts " rails-ai-context tool NAME # Run any of the #{RailsAiContext::CountPhrase.call(builtin_tool_count, "tool")}"
420
450
  if tool_mode == :mcp
421
451
  $stderr.puts " rails-ai-context serve # Start MCP server"
@@ -562,11 +592,20 @@ class RailsAiContextCLI < Thor
562
592
  install/program mcp_config_generator legacy_cleanup
563
593
  ].each { |file| require_relative "../lib/rails_ai_context/#{file}" }
564
594
 
565
- return if ::RailsAiContext.respond_to?(:log_warn)
595
+ # Both are defined in the entry file, which is the thing that must not
596
+ # load yet. With no Rails in the process log_warn only writes to stderr.
597
+ unless ::RailsAiContext.respond_to?(:log_warn)
598
+ ::RailsAiContext.define_singleton_method(:log_warn) { |message| $stderr.puts(message) }
599
+ end
600
+
601
+ return if ::RailsAiContext.respond_to?(:debug_fail)
566
602
 
567
- # Defined in the entry file, which is the thing that must not load yet.
568
- # With no Rails in the process it only writes to stderr.
569
- ::RailsAiContext.define_singleton_method(:log_warn) { |message| $stderr.puts(message) }
603
+ # Every rescue in the files above routes through this, so without it a
604
+ # rescue that fires raises NoMethodError over the error it was catching.
605
+ ::RailsAiContext.define_singleton_method(:debug_fail) do |error, fallback = nil, label:|
606
+ $stderr.puts("[rails-ai-context] #{label} failed: #{error.message}") if ENV["DEBUG"]
607
+ fallback
608
+ end
570
609
  end
571
610
  end
572
611
 
@@ -38,6 +38,9 @@ module RailsAiContext
38
38
  class_option :defaults, type: :boolean, default: false,
39
39
  desc: "Skip all interactive prompts and use each prompt's documented default (for CI/non-interactive use)"
40
40
 
41
+ class_option :mcp_only, type: :boolean, default: false,
42
+ desc: "Write the MCP config and nothing else: no CLAUDE.md, AGENTS.md, rules files or .ai-context.json"
43
+
41
44
  BARE_GUARD_PATTERN = RailsAiContext::Install::InitializerFile::BARE_GUARD
42
45
 
43
46
  def select_ai_tools
@@ -54,8 +57,16 @@ module RailsAiContext
54
57
  )
55
58
  end
56
59
 
57
- def select_tool_mode
58
- @tool_mode = RailsAiContext::Install::Program.select_tool_mode(program_surface)
60
+ def select_setup
61
+ if options[:mcp_only]
62
+ @tool_mode = :mcp
63
+ @context_files = false
64
+ return
65
+ end
66
+
67
+ setup = RailsAiContext::Install::Program.select_setup(program_surface)
68
+ @tool_mode = setup.tool_mode
69
+ @context_files = setup.context_files
59
70
  end
60
71
 
61
72
  def create_mcp_config
@@ -79,6 +90,11 @@ module RailsAiContext
79
90
  # :mcp - MCP primary + CLI fallback (default, requires `rails ai:serve`)
80
91
  # :cli - CLI only (no MCP server needed, uses `rails 'ai:tool[NAME]'`)
81
92
  # config.tool_mode = :mcp
93
+
94
+ # Whether this gem writes context files at all. false is MCP-only:
95
+ # the server and the CLI still answer, and CLAUDE.md, AGENTS.md,
96
+ # the rules directories and .ai-context.json are left alone.
97
+ # config.context_files = true
82
98
  SECTION
83
99
  "Introspection" => <<~SECTION,
84
100
  # ── Introspection ─────────────────────────────────────────────────
@@ -261,11 +277,7 @@ module RailsAiContext
261
277
  # Always write uncommented so re-install can detect previous selection
262
278
  tools_line = RailsAiContext::Install::SelectionRecord.initializer_line(@selected_formats)
263
279
 
264
- tool_mode_line = if @tool_mode == :cli
265
- " config.tool_mode = :cli # CLI only (no MCP server needed)"
266
- else
267
- " config.tool_mode = :mcp # MCP primary + CLI fallback"
268
- end
280
+ tool_mode_line = build_tool_mode_line
269
281
 
270
282
  content = "# frozen_string_literal: true\n\nRailsAiContext.configure do |config|\n"
271
283
 
@@ -280,6 +292,7 @@ module RailsAiContext
280
292
  # :mcp - MCP primary + CLI fallback (default, requires `rails ai:serve`)
281
293
  # :cli - CLI only (no MCP server needed, uses `rails 'ai:tool[NAME]'`)
282
294
  #{tool_mode_line}
295
+ #{build_context_files_line}
283
296
 
284
297
  SECTION
285
298
 
@@ -312,7 +325,11 @@ module RailsAiContext
312
325
  existing, changed = update_config_line(existing, "config.tool_mode", build_tool_mode_line)
313
326
  changes << "tool_mode" if changed
314
327
 
315
- # 3. Add any missing config sections
328
+ # 3. Record whether this install writes context files at all
329
+ existing, changed = update_config_line(existing, "config.context_files", build_context_files_line)
330
+ changes << "context_files" if changed
331
+
332
+ # 4. Add any missing config sections
316
333
  CONFIG_SECTIONS.each do |name, section_content|
317
334
  marker = "── #{name}"
318
335
  next if existing.include?(marker)
@@ -423,6 +440,20 @@ module RailsAiContext
423
440
  end
424
441
  end
425
442
 
443
+ # Written uncommented either way, so a re-run reads the same answer the
444
+ # last one recorded.
445
+ def build_context_files_line
446
+ if context_files?
447
+ " config.context_files = true # write CLAUDE.md, AGENTS.md and rules files"
448
+ else
449
+ " config.context_files = false # MCP only: no context files are written"
450
+ end
451
+ end
452
+
453
+ def context_files?
454
+ @context_files.nil? ? true : @context_files
455
+ end
456
+
426
457
  def read_previous_ai_tools
427
458
  RailsAiContext::Install::SelectionRecord.read(root: Rails.root)
428
459
  end
@@ -435,7 +466,7 @@ module RailsAiContext
435
466
  # comment behind. One writer per file, and it is not this call.
436
467
  result = RailsAiContext::Install::SelectionRecord.write(
437
468
  @selected_formats, root: Rails.root,
438
- extra_yaml: { "tool_mode" => @tool_mode.to_s },
469
+ extra_yaml: { "tool_mode" => @tool_mode.to_s, "context_files" => context_files? },
439
470
  initializer: false
440
471
  )
441
472
 
@@ -445,7 +476,8 @@ module RailsAiContext
445
476
  end
446
477
 
447
478
  def add_to_gitignore
448
- RailsAiContext::Install::Program.mark_gitignore(program_surface, root: Rails.root)
479
+ RailsAiContext::Install::Program.mark_gitignore(program_surface, root: Rails.root,
480
+ context_files: context_files?)
449
481
  end
450
482
 
451
483
  def install_validation_hook
@@ -505,6 +537,12 @@ module RailsAiContext
505
537
  end
506
538
 
507
539
  def generate_context_files
540
+ unless context_files?
541
+ say ""
542
+ say "MCP-only install: no context files written.", :yellow
543
+ return
544
+ end
545
+
508
546
  say ""
509
547
  say "Generating AI context files...", :yellow
510
548
 
@@ -540,7 +578,12 @@ module RailsAiContext
540
578
  say "Your setup:", :yellow
541
579
  RailsAiContext::Install::AiTool.all.each do |tool|
542
580
  next unless @selected_formats.include?(tool.key)
543
- say " ✅ #{tool.name.ljust(16)} -> #{tool.files}"
581
+ files = context_files? ? tool.files : "MCP config only"
582
+ say " ✅ #{tool.name.ljust(16)} -> #{files}"
583
+ end
584
+ unless context_files?
585
+ say ""
586
+ say " Left alone on purpose: CLAUDE.md, AGENTS.md, the rules directories and .ai-context.json.", :yellow
544
587
  end
545
588
  say ""
546
589
  say "Commands:", :yellow
@@ -55,7 +55,11 @@ module RailsAiContext
55
55
  .reject { |f| inherited_skips.include?(f[:name].to_s) && !f[:declared] }
56
56
  # Keyed by kind and name: `after_action :audit` and `before_action :audit`
57
57
  # are two entries in the chain and Rails runs both.
58
- declared_on = parent.to_h { |f| [ entry_key(f), f[:from] ] }
58
+ # The whole ancestor entry, not only its `from:`: an entry the walk
59
+ # could not attribute carries `provenance` instead, and merging only
60
+ # `from` dropped that on the tier the label exists for.
61
+ attribution_of = parent.to_h { |f| [ entry_key(f), f.slice(:from, :provenance) ] }
62
+ declared_on = attribution_of
59
63
  declared_names = declared.map { |f| entry_key(f) }.to_set
60
64
 
61
65
  # A filter this body declares is its own, whatever an ancestor declares
@@ -66,7 +70,7 @@ module RailsAiContext
66
70
  inherited = mark_conditional_skips(
67
71
  parent.reject { |f| declared_names.include?(entry_key(f)) } +
68
72
  applicable.select(&inherited_here)
69
- .map { |f| f.merge(from: declared_on[entry_key(f)]) }, conditions, action
73
+ .map { |f| f.except(:from, :provenance).merge(attribution_of[entry_key(f)]) }, conditions, action
70
74
  )
71
75
 
72
76
  { own: own,
@@ -178,11 +182,17 @@ module RailsAiContext
178
182
  attributed = Set.new
179
183
  conditions = {}
180
184
  declares = Set.new
185
+ # Where in the walk each entry was first seen, so the list can be
186
+ # emitted root first while the order inside one class is kept.
187
+ positions = {}
188
+ evidence = {}
189
+ depth = 0
181
190
  dropped = skipped.map(&:to_s).to_set
182
191
  name = Introspectors::ActionResolver.resolve_entry_name(controllers, parent_class, within)
183
192
 
184
193
  while name && !seen.include?(name)
185
194
  seen << name
195
+ depth += 1
186
196
  info = controllers[name]
187
197
  source = info.is_a?(Hash) ? nil : base_controller_source(name, root)
188
198
  info ||= { filters: Introspectors::ControllerFilters.from_source(source) } if source
@@ -201,29 +211,56 @@ module RailsAiContext
201
211
  # the walk did see is never mistaken for a skip of a declaration it
202
212
  # could not. A skip record is not a sighting, hence the reject above.
203
213
  declares.merge(carried.map { |f| f[:name].to_s })
214
+ # Whether this class's own body was read at all. Without that, an
215
+ # unmarked filter is a filter nobody could check, not one a gem
216
+ # installed - an engine's ApplicationController and a concern-only
217
+ # payload both land there, and dropping their attribution would lose
218
+ # the answer rather than correct it.
219
+ body_known = carried.any? { |f| f[:declared] }
204
220
  carried.select { |f| applies?(f, action) }
205
221
  .reject { |f| dropped.include?(f[:name].to_s) }
206
- .each { |f| record_attribution(found, attributed, f, name) }
222
+ .each { |f| record_attribution(found, attributed, f, name, positions, depth, evidence, body_known) }
207
223
 
208
224
  name = Introspectors::ActionResolver.resolve_entry_name(controllers, info[:parent_class], name)
209
225
  end
210
226
 
211
- [ found.values, dropped, conditions, declares ]
227
+ [ run_order(found, attributed, positions, evidence), dropped, conditions, declares ]
212
228
  end
213
229
 
214
230
  # The closest ancestor carrying a filter keeps its constraints, but a
215
231
  # booted ancestor carries names it only inherits, so `from:` moves on to
216
232
  # the first ancestor whose own body declared it.
217
- def record_attribution(found, attributed, filter, ancestor)
233
+ def record_attribution(found, attributed, filter, ancestor, positions = {}, depth = 0,
234
+ evidence = {}, body_known = false)
218
235
  key = entry_key(filter)
219
236
  if found.key?(key)
220
237
  found[key] = found[key].merge(from: ancestor) if filter[:declared] && !attributed.include?(key)
221
238
  else
222
239
  found[key] = filter.merge(from: ancestor)
240
+ positions[key] = depth
241
+ evidence[key] = body_known
223
242
  end
224
243
  attributed << key if filter[:declared]
225
244
  end
226
245
 
246
+ # Rails runs the root's callbacks first, so the inherited list reads that
247
+ # way: the walk's class order reversed, each class's own order kept. A
248
+ # filter no ancestor's body declares is installed from somewhere else -
249
+ # a gem's `on_load :action_controller` block, the framework, a concern -
250
+ # and crediting it to the nearest app class sent an agent to a file that
251
+ # never mentions it.
252
+ def run_order(found, attributed, positions, evidence = {})
253
+ found.keys
254
+ .each_with_index
255
+ .sort_by { |key, index| [ -positions.fetch(key, 0), index ] }
256
+ .map do |key, _|
257
+ entry = found[key]
258
+ next entry if attributed.include?(key) || !evidence[key]
259
+
260
+ entry.merge(from: nil, provenance: "not declared in the controller chain").compact
261
+ end
262
+ end
263
+
227
264
  # One entry in the chain. A skip names a filter by name, and a chain entry
228
265
  # is a kind and a name: a class can declare both `before_action :audit` and
229
266
  # `after_action :audit`, and both run.
@@ -308,8 +345,7 @@ module RailsAiContext
308
345
  end
309
346
  end.uniq { |skip| skip[:name] }
310
347
  rescue => e
311
- $stderr.puts "[rails-ai-context] ActionFilters skip_source_records failed: #{e.message}" if ENV["DEBUG"]
312
- []
348
+ RailsAiContext.debug_fail(e, [], label: "ActionFilters skip_source_records")
313
349
  end
314
350
 
315
351
  # ApplicationController is deliberately not in the listing: it would sit in
@@ -39,11 +39,32 @@ module RailsAiContext
39
39
  status == :booted
40
40
  end
41
41
 
42
- # One-line summary safe to relay to an AI client or a terminal.
42
+ # Bundler::GemRequireError names the gem it failed to require and
43
+ # nothing about why; the incompatibility that actually raised is in
44
+ # `cause`. Walk to the deepest one, capped so a cycle cannot hang.
45
+ def root_cause
46
+ cause = error&.cause
47
+ 10.times do
48
+ break unless cause&.cause
49
+
50
+ cause = cause.cause
51
+ end
52
+ cause
53
+ end
54
+
55
+ # One-line summary safe to relay to an AI client or a terminal. It
56
+ # carries the cause too: the footer this feeds is the only place most
57
+ # callers ever see, and a gem name with no reason is not actionable.
43
58
  def failure_summary
44
59
  return nil if booted?
45
60
 
46
- "#{error.class}: #{error.message.to_s.lines.first&.strip}"
61
+ summary = one_line(error)
62
+ cause = root_cause
63
+ cause ? "#{summary} (cause: #{one_line(cause)})" : summary
64
+ end
65
+
66
+ def one_line(e)
67
+ "#{e.class}: #{e.message.to_s.lines.first&.strip}"
47
68
  end
48
69
 
49
70
  # An unguarded `RailsAiContext.configure` in config/initializers has
@@ -129,6 +129,12 @@ module RailsAiContext
129
129
  messages << " #{result.failure_summary}"
130
130
  if ENV["DEBUG"]
131
131
  Array(result.error.backtrace).first(15).each { |line| messages << " #{line}" }
132
+ # The wrapper's frames stop at the require; the frames that name the
133
+ # incompatible call are the cause's.
134
+ if (cause = result.root_cause)
135
+ messages << " Raised by:"
136
+ Array(cause.backtrace).first(15).each { |line| messages << " #{line}" }
137
+ end
132
138
  else
133
139
  messages << " Run with DEBUG=1 for the full backtrace."
134
140
  end
@@ -218,9 +218,9 @@ module RailsAiContext
218
218
  # `--files=a.rb b.rb` is the same call as `--files a.rb b.rb`;
219
219
  # honouring only one of the two spellings docs/CLI.md teaches
220
220
  # left the other still dropping every file after the first.
221
- trailing = collect_array_values(args, i + 1)
221
+ trailing, consumed = array_values(args, i + 1)
222
222
  result[key] = Array(coerce_value(value, prop, key)) + trailing
223
- i += 1 + values_consumed(args, i + 1)
223
+ i += 1 + consumed
224
224
  next
225
225
  end
226
226
  result[key] = coerce_value(value, prop, key)
@@ -244,21 +244,12 @@ module RailsAiContext
244
244
  next
245
245
  end
246
246
 
247
- if prop[:type] == "array"
248
- values = collect_array_values(args, i + 1)
249
- unless values.empty?
250
- result[key] = values
251
- i += 1 + values_consumed(args, i + 1)
252
- next
253
- end
254
- end
255
-
256
247
  # A bare `--files` with nothing after it is a mistake, not a
257
248
  # request for `files: true` - an array param holding a Boolean
258
249
  # reaches the tool as a type it never accepts.
259
250
  if prop[:type] == "array"
260
- result[key] = []
261
- i += 1
251
+ result[key], consumed = array_values(args, i + 1)
252
+ i += 1 + consumed
262
253
  next
263
254
  end
264
255
 
@@ -335,15 +326,12 @@ module RailsAiContext
335
326
  # stopping at a rake-style `key=value` token so `--include a model=Post`
336
327
  # does not swallow the second parameter. Each token is still
337
328
  # comma-split, so the documented `a.rb,b.rb` form keeps working and
338
- # mixing the two spellings does not fabricate a path.
339
- def collect_array_values(args, from)
340
- args[from..].to_a
341
- .take_while { |a| !a.start_with?("--") && !a.include?("=") }
342
- .flat_map { |a| Array(coerce_value(a, { type: "array" })) }
343
- end
344
-
345
- def values_consumed(args, from)
346
- args[from..].to_a.take_while { |a| !a.start_with?("--") && !a.include?("=") }.size
329
+ # mixing the two spellings does not fabricate a path. The token count
330
+ # comes back with the values because the cursor advances by tokens: a
331
+ # comma token yields more values than it consumed.
332
+ def array_values(args, from)
333
+ tokens = args[from..].to_a.take_while { |a| !a.start_with?("--") && !a.include?("=") }
334
+ [ tokens.flat_map { |a| Array(coerce_value(a, { type: "array" })) }, tokens.size ]
347
335
  end
348
336
 
349
337
  # Coerce a string value to the type specified in the JSON Schema property.
@@ -88,8 +88,7 @@ module RailsAiContext
88
88
  # A permission bit, a directory in place of a file and a bug in a
89
89
  # listener all land in `unresolved` alike, so the cause is worth
90
90
  # saying where the booted walk already says it.
91
- $stderr.puts "[rails-ai-context] concern introspection failed for #{path}: #{e.message}" if ENV["DEBUG"]
92
- nil
91
+ RailsAiContext.debug_fail(e, nil, label: "concern introspection of #{path}")
93
92
  end
94
93
 
95
94
  def tagged(entry, concern_name)
@@ -23,7 +23,12 @@ module RailsAiContext
23
23
  configured = RailsAiContext.configuration.concern_paths
24
24
  dirs =
25
25
  if configured.nil?
26
- Dir.glob(File.join(root, "app", "*", "concerns"))
26
+ # Every app tree the app has, not just the one at the root: a
27
+ # packwerk pack or an in-repo engine keeps its own app/*/concerns.
28
+ # `dirs_for` cannot take the glob directly - it tests each candidate
29
+ # with `Dir.exist?`, which a literal `app/*/concerns` never passes -
30
+ # so the app trees are resolved first and globbed here.
31
+ PathResolver.dirs_for(root, "app").flat_map { |app_dir| Dir.glob(File.join(app_dir, "*", "concerns")) }
27
32
  else
28
33
  # A path that is already absolute is taken as given; `File.join`
29
34
  # would graft it onto the root and point at nothing.
@@ -13,7 +13,7 @@ module RailsAiContext
13
13
 
14
14
  # All YAML-supported keys (explicit allowlist for safety)
15
15
  YAML_KEYS = %i[
16
- ai_tools tool_mode preset context_mode generate_root_files claude_max_lines
16
+ ai_tools tool_mode preset context_mode context_files generate_root_files claude_max_lines
17
17
  anti_hallucination_rules
18
18
  server_name cache_ttl max_tool_response_chars
19
19
  live_reload live_reload_debounce auto_mount http_path http_bind http_port
@@ -256,6 +256,12 @@ module RailsAiContext
256
256
  # for the security review that changed the default.
257
257
  attr_accessor :instrumentation_include_arguments
258
258
 
259
+ # Whether this gem writes context files at all. False is MCP-only: the
260
+ # server and the CLI still answer, and no CLAUDE.md, AGENTS.md, rules
261
+ # file or .ai-context.json is written or touched. Narrower than
262
+ # `generate_root_files`, which still writes the split rule files.
263
+ attr_accessor :context_files
264
+
259
265
  # Whether to generate root-level context files (CLAUDE.md, AGENTS.md, etc.)
260
266
  # When false, only generates split rule files (.claude/rules/, .cursor/rules/, etc.)
261
267
  attr_accessor :generate_root_files
@@ -408,6 +414,7 @@ module RailsAiContext
408
414
  @live_reload = :auto
409
415
  @live_reload_debounce = 1.5
410
416
  @instrumentation_include_arguments = false
417
+ @context_files = true
411
418
  @generate_root_files = true
412
419
  @anti_hallucination_rules = true
413
420
  @max_file_size = 5_000_000
@@ -15,10 +15,13 @@ module RailsAiContext
15
15
 
16
16
  ORDER = { SUMMARY => 0, STANDARD => 1, FULL => 2 }.freeze
17
17
 
18
- # The enum tools publish in their input schema, so the advertised values
19
- # and the normalizer cannot drift apart. Spelling the values in a tool
20
- # instead fails the enum-ownership spec.
21
- SCHEMA_ENUM = ALL
18
+ # The `detail` property tools publish in their input schema. Tools pass
19
+ # their own wording and get the type and the enum from here, so the
20
+ # advertised values and the normalizer cannot drift apart. Spelling the
21
+ # values in a tool instead fails the enum-ownership spec.
22
+ def self.schema(description)
23
+ { type: "string", enum: ALL, description: description }
24
+ end
22
25
 
23
26
  def self.valid?(detail)
24
27
  ALL.include?(detail.to_s)
@@ -197,6 +197,10 @@ module RailsAiContext
197
197
  end
198
198
 
199
199
  def check_context_freshness
200
+ # An MCP-only install asked for no context files, so their absence is
201
+ # the configuration working, not something to fix.
202
+ return nil unless RailsAiContext.configuration.context_files
203
+
200
204
  ai_tools = configured_ai_tools
201
205
 
202
206
  # Find the first existing context file or split rule directory for configured tools
@@ -440,8 +444,7 @@ module RailsAiContext
440
444
  fix: introspector_failure_hint(failures))
441
445
  end
442
446
  rescue StandardError, ScriptError => e
443
- $stderr.puts "[rails-ai-context] check_introspector_health failed: #{e.message}" if ENV["DEBUG"]
444
- nil
447
+ RailsAiContext.debug_fail(e, nil, label: "check_introspector_health")
445
448
  end
446
449
 
447
450
  MAX_SHOWN_FAILURES = 3
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ # The Ruby inside ERB tags. Two readers need it for different reasons - a
5
+ # template's ivars, and the ENV names a `.yml` or a view reads - and a
6
+ # second copy of the tag regex is a second answer to "what is a tag".
7
+ module ErbSource
8
+ TAG = /<%={0,2}-?(.*?)-?%>/m
9
+
10
+ module_function
11
+
12
+ # @return [Boolean] whether the source carries any ERB tag at all
13
+ def tagged?(source)
14
+ source.to_s.include?("<%")
15
+ end
16
+
17
+ # The tag bodies, joined. Order is kept; line numbers are not.
18
+ def tag_bodies(source)
19
+ source.to_s.scan(TAG).flatten.join("\n")
20
+ end
21
+
22
+ # The same Ruby with everything outside the tags blanked rather than
23
+ # dropped, so a line number in the result is still the line in the file.
24
+ # A `<%#` comment is blanked too.
25
+ def ruby_in_place(source)
26
+ text = source.to_s
27
+ out = +""
28
+ last = 0
29
+ text.to_enum(:scan, TAG).each do
30
+ match = Regexp.last_match
31
+ out << blank(text[last...match.begin(0)])
32
+ body = match[1].to_s
33
+ out << (body.lstrip.start_with?("#") ? blank(body) : body)
34
+ last = match.end(0)
35
+ end
36
+ out << blank(text[last..].to_s)
37
+ out
38
+ end
39
+
40
+ def blank(text)
41
+ text.gsub(/[^\n]/, " ")
42
+ end
43
+ private_class_method :blank
44
+ end
45
+ end