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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +329 -0
- data/README.md +3 -1
- data/docs/ARCHITECTURE.md +2 -2
- data/docs/COMPATIBILITY.md +4 -1
- data/docs/CONFIGURATION.md +27 -0
- data/docs/FAQ.md +13 -0
- data/docs/INTROSPECTORS.md +1 -1
- data/docs/QUICKSTART.md +5 -1
- data/docs/SETUP.md +4 -0
- data/docs/STANDALONE.md +3 -0
- data/docs/TOOLS.md +12 -6
- data/exe/rails-ai-context +59 -20
- data/lib/generators/rails_ai_context/install/install_generator.rb +54 -11
- data/lib/rails_ai_context/action_filters.rb +43 -7
- data/lib/rails_ai_context/boot_manager.rb +23 -2
- data/lib/rails_ai_context/cli/entry_boot.rb +6 -0
- data/lib/rails_ai_context/cli/tool_runner.rb +10 -22
- data/lib/rails_ai_context/concern_macros.rb +1 -2
- data/lib/rails_ai_context/concern_paths.rb +6 -1
- data/lib/rails_ai_context/configuration.rb +8 -1
- data/lib/rails_ai_context/detail_level.rb +7 -4
- data/lib/rails_ai_context/doctor.rb +5 -2
- data/lib/rails_ai_context/erb_source.rb +45 -0
- data/lib/rails_ai_context/fingerprinter.rb +1 -2
- data/lib/rails_ai_context/gem_lock.rb +18 -2
- data/lib/rails_ai_context/hydrators/controller_hydrator.rb +3 -6
- data/lib/rails_ai_context/hydrators/model_hints.rb +5 -2
- data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +1 -1
- data/lib/rails_ai_context/hydrators/view_hydrator.rb +1 -2
- data/lib/rails_ai_context/install/cleanup.rb +23 -7
- data/lib/rails_ai_context/install/program.rb +37 -13
- data/lib/rails_ai_context/install/selection_record.rb +72 -0
- data/lib/rails_ai_context/install_mode.rb +1 -2
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -2
- data/lib/rails_ai_context/introspectors/action_resolver.rb +45 -13
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -4
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +4 -8
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +17 -16
- data/lib/rails_ai_context/introspectors/api_introspector.rb +92 -21
- data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +2 -8
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +11 -22
- data/lib/rails_ai_context/introspectors/autoload_introspector.rb +6 -12
- data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -15
- data/lib/rails_ai_context/introspectors/config_introspector.rb +9 -64
- data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +5 -10
- data/lib/rails_ai_context/introspectors/controller_filters.rb +1 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +23 -114
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +21 -49
- data/lib/rails_ai_context/introspectors/credentials_introspector.rb +3 -6
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +3 -6
- data/lib/rails_ai_context/introspectors/declared_constant.rb +2 -4
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +35 -56
- data/lib/rails_ai_context/introspectors/eager_load.rb +1 -2
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +2 -4
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +48 -11
- data/lib/rails_ai_context/introspectors/env_introspector.rb +2 -4
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +12 -31
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +60 -8
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +48 -48
- data/lib/rails_ai_context/introspectors/initializer_introspector.rb +4 -8
- data/lib/rails_ai_context/introspectors/job_introspector.rb +89 -18
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +3 -8
- data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +36 -0
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +22 -4
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +3 -6
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +2 -4
- data/lib/rails_ai_context/introspectors/migration_replay.rb +21 -36
- data/lib/rails_ai_context/introspectors/model_introspector.rb +58 -30
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +5 -10
- data/lib/rails_ai_context/introspectors/observability_introspector.rb +6 -12
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +73 -30
- data/lib/rails_ai_context/introspectors/route_introspector.rb +37 -30
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +6 -12
- data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -2
- data/lib/rails_ai_context/introspectors/security_introspector.rb +9 -18
- data/lib/rails_ai_context/introspectors/source_introspector.rb +1 -2
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +6 -51
- data/lib/rails_ai_context/introspectors/table_name.rb +1 -2
- data/lib/rails_ai_context/introspectors/test_introspector.rb +20 -46
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +19 -51
- data/lib/rails_ai_context/introspectors/view_introspector.rb +6 -10
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +19 -10
- data/lib/rails_ai_context/legacy_cleanup.rb +3 -1
- data/lib/rails_ai_context/mcp_config_generator.rb +42 -67
- data/lib/rails_ai_context/migration_status.rb +1 -2
- data/lib/rails_ai_context/output_guard.rb +43 -3
- data/lib/rails_ai_context/package_json.rb +98 -0
- data/lib/rails_ai_context/payload.rb +45 -0
- data/lib/rails_ai_context/portable_path.rb +1 -2
- data/lib/rails_ai_context/redaction.rb +26 -10
- data/lib/rails_ai_context/schema_adapter.rb +2 -2
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +6 -34
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +1 -37
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +1 -40
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +41 -0
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -1
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +37 -16
- data/lib/rails_ai_context/tools/analyze_feature.rb +45 -73
- data/lib/rails_ai_context/tools/base_tool.rb +41 -35
- data/lib/rails_ai_context/tools/dependency_graph.rb +96 -15
- data/lib/rails_ai_context/tools/diagnose.rb +2 -4
- data/lib/rails_ai_context/tools/generate_test.rb +144 -54
- data/lib/rails_ai_context/tools/get_active_support.rb +5 -1
- data/lib/rails_ai_context/tools/get_api.rb +25 -6
- data/lib/rails_ai_context/tools/get_callbacks.rb +7 -11
- data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -5
- data/lib/rails_ai_context/tools/get_concern.rb +43 -57
- data/lib/rails_ai_context/tools/get_context.rb +2 -1
- data/lib/rails_ai_context/tools/get_controllers.rb +54 -34
- data/lib/rails_ai_context/tools/get_conventions.rb +25 -29
- data/lib/rails_ai_context/tools/get_env.rb +99 -116
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -5
- data/lib/rails_ai_context/tools/get_gems.rb +9 -2
- data/lib/rails_ai_context/tools/get_helper_methods.rb +58 -51
- data/lib/rails_ai_context/tools/get_job_pattern.rb +29 -18
- data/lib/rails_ai_context/tools/get_model_details.rb +10 -25
- data/lib/rails_ai_context/tools/get_partial_interface.rb +21 -40
- data/lib/rails_ai_context/tools/get_routes.rb +7 -7
- data/lib/rails_ai_context/tools/get_schema.rb +3 -9
- data/lib/rails_ai_context/tools/get_service_pattern.rb +134 -51
- data/lib/rails_ai_context/tools/get_stimulus.rb +10 -13
- data/lib/rails_ai_context/tools/get_test_info.rb +3 -9
- data/lib/rails_ai_context/tools/get_turbo_map.rb +4 -11
- data/lib/rails_ai_context/tools/get_view.rb +12 -10
- data/lib/rails_ai_context/tools/migration_advisor.rb +2 -4
- data/lib/rails_ai_context/tools/onboard.rb +6 -223
- data/lib/rails_ai_context/tools/performance_check.rb +28 -35
- data/lib/rails_ai_context/tools/query.rb +6 -1
- data/lib/rails_ai_context/tools/runtime_info.rb +4 -11
- data/lib/rails_ai_context/tools/search_code.rb +41 -21
- data/lib/rails_ai_context/tools/search_docs.rb +1 -2
- data/lib/rails_ai_context/tools/section_fetch.rb +1 -2
- data/lib/rails_ai_context/tools/security_scan.rb +1 -5
- data/lib/rails_ai_context/tools/session_context.rb +1 -2
- data/lib/rails_ai_context/tools/validate.rb +1 -2
- data/lib/rails_ai_context/tools/validate_semantics.rb +42 -17
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +9 -18
- data/lib/rails_ai_context/view_file.rb +38 -0
- data/lib/rails_ai_context/watcher.rb +4 -1
- data/lib/rails_ai_context.rb +22 -1
- data/server.json +1 -1
- metadata +3 -2
- 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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
371
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
#
|
|
568
|
-
#
|
|
569
|
-
::RailsAiContext.define_singleton_method(:
|
|
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
|
|
58
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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 =
|
|
221
|
+
trailing, consumed = array_values(args, i + 1)
|
|
222
222
|
result[key] = Array(coerce_value(value, prop, key)) + trailing
|
|
223
|
-
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
|
|
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
|
-
|
|
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
|