rails-ai-context 4.3.0 → 4.3.2

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -8
  3. data/CLAUDE.md +4 -4
  4. data/CONTRIBUTING.md +1 -1
  5. data/README.md +7 -7
  6. data/SECURITY.md +2 -1
  7. data/docs/GUIDE.md +3 -3
  8. data/lib/generators/rails_ai_context/install/install_generator.rb +2 -2
  9. data/lib/rails_ai_context/configuration.rb +4 -2
  10. data/lib/rails_ai_context/doctor.rb +6 -1
  11. data/lib/rails_ai_context/fingerprinter.rb +24 -0
  12. data/lib/rails_ai_context/introspectors/component_introspector.rb +122 -7
  13. data/lib/rails_ai_context/introspectors/performance_introspector.rb +31 -26
  14. data/lib/rails_ai_context/introspectors/schema_introspector.rb +183 -6
  15. data/lib/rails_ai_context/introspectors/view_introspector.rb +2 -2
  16. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +61 -8
  17. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +13 -22
  18. data/lib/rails_ai_context/serializers/claude_serializer.rb +15 -3
  19. data/lib/rails_ai_context/serializers/context_file_serializer.rb +15 -3
  20. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +3 -3
  21. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +5 -5
  22. data/lib/rails_ai_context/serializers/design_system_helper.rb +8 -1
  23. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +3 -3
  24. data/lib/rails_ai_context/serializers/opencode_serializer.rb +0 -1
  25. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +15 -9
  26. data/lib/rails_ai_context/server.rb +8 -1
  27. data/lib/rails_ai_context/tools/analyze_feature.rb +24 -1
  28. data/lib/rails_ai_context/tools/base_tool.rb +78 -1
  29. data/lib/rails_ai_context/tools/dependency_graph.rb +4 -1
  30. data/lib/rails_ai_context/tools/diagnose.rb +135 -8
  31. data/lib/rails_ai_context/tools/generate_test.rb +87 -7
  32. data/lib/rails_ai_context/tools/get_callbacks.rb +27 -4
  33. data/lib/rails_ai_context/tools/get_component_catalog.rb +11 -2
  34. data/lib/rails_ai_context/tools/get_context.rb +71 -8
  35. data/lib/rails_ai_context/tools/get_conventions.rb +59 -0
  36. data/lib/rails_ai_context/tools/get_design_system.rb +45 -7
  37. data/lib/rails_ai_context/tools/get_edit_context.rb +3 -2
  38. data/lib/rails_ai_context/tools/get_env.rb +51 -24
  39. data/lib/rails_ai_context/tools/get_frontend_stack.rb +100 -9
  40. data/lib/rails_ai_context/tools/get_model_details.rb +20 -0
  41. data/lib/rails_ai_context/tools/get_partial_interface.rb +12 -5
  42. data/lib/rails_ai_context/tools/get_schema.rb +1 -0
  43. data/lib/rails_ai_context/tools/get_stimulus.rb +13 -7
  44. data/lib/rails_ai_context/tools/get_turbo_map.rb +35 -2
  45. data/lib/rails_ai_context/tools/get_view.rb +65 -9
  46. data/lib/rails_ai_context/tools/migration_advisor.rb +10 -3
  47. data/lib/rails_ai_context/tools/onboard.rb +413 -27
  48. data/lib/rails_ai_context/tools/performance_check.rb +45 -28
  49. data/lib/rails_ai_context/tools/query.rb +28 -2
  50. data/lib/rails_ai_context/tools/read_logs.rb +4 -1
  51. data/lib/rails_ai_context/tools/review_changes.rb +27 -17
  52. data/lib/rails_ai_context/tools/runtime_info.rb +289 -0
  53. data/lib/rails_ai_context/tools/search_code.rb +23 -4
  54. data/lib/rails_ai_context/tools/security_scan.rb +7 -1
  55. data/lib/rails_ai_context/tools/session_context.rb +137 -0
  56. data/lib/rails_ai_context/tools/validate.rb +5 -0
  57. data/lib/rails_ai_context/version.rb +1 -1
  58. metadata +6 -4
@@ -156,21 +156,33 @@ module RailsAiContext
156
156
  File.join(app.root, "db", "structure.sql")
157
157
  end
158
158
 
159
+ def migrations_dir
160
+ File.join(app.root, "db", "migrate")
161
+ end
162
+
159
163
  def max_schema_file_size
160
164
  RailsAiContext.configuration.max_schema_file_size
161
165
  end
162
166
 
163
167
  # Fallback: parse schema file as text when DB isn't connected.
164
- # Tries db/schema.rb first, then db/structure.sql.
168
+ # Tries db/schema.rb first, then db/structure.sql, then migrations.
165
169
  # This enables introspection in CI, Claude Code, etc.
166
170
  def static_schema_parse
167
171
  if File.exist?(schema_file_path)
168
- parse_schema_rb(schema_file_path)
169
- elsif File.exist?(structure_file_path)
170
- parse_structure_sql(structure_file_path)
171
- else
172
- { error: "No db/schema.rb or db/structure.sql found" }
172
+ result = parse_schema_rb(schema_file_path)
173
+ return result if result[:total_tables].to_i > 0
174
+ end
175
+
176
+ if File.exist?(structure_file_path)
177
+ result = parse_structure_sql(structure_file_path)
178
+ return result if result[:total_tables].to_i > 0
173
179
  end
180
+
181
+ if Dir.exist?(migrations_dir) && Dir.glob(File.join(migrations_dir, "*.rb")).any?
182
+ return parse_migrations
183
+ end
184
+
185
+ { error: "No db/schema.rb, db/structure.sql, or migrations found" }
174
186
  end
175
187
 
176
188
  def parse_schema_rb(path)
@@ -374,6 +386,171 @@ module RailsAiContext
374
386
  []
375
387
  end
376
388
 
389
+ # Reconstruct schema by replaying migrations in order.
390
+ # Handles: create_table, add_column, remove_column, rename_column,
391
+ # rename_table, drop_table, change_column, add_index, add_reference,
392
+ # add_foreign_key, add_timestamps.
393
+ def parse_migrations
394
+ tables = {}
395
+ migration_files = Dir.glob(File.join(migrations_dir, "*.rb")).sort
396
+
397
+ migration_files.each do |path|
398
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
399
+ replay_migration(content, tables)
400
+ rescue => e
401
+ next # Skip unparseable migrations
402
+ end
403
+
404
+ # Remove internal Rails tables
405
+ tables.delete("ar_internal_metadata")
406
+ tables.delete("schema_migrations")
407
+
408
+ {
409
+ adapter: "static_parse",
410
+ tables: tables,
411
+ total_tables: tables.size,
412
+ note: "Reconstructed from #{migration_files.size} migration files (no DB connection, no schema.rb)"
413
+ }
414
+ end
415
+
416
+ def replay_migration(content, tables) # rubocop:disable Metrics
417
+ current_table = nil
418
+
419
+ content.each_line do |line|
420
+ stripped = line.strip
421
+
422
+ # create_table :name / create_table "name"
423
+ if (match = stripped.match(/create_table\s+[:"'](\w+)/))
424
+ table_name = match[1]
425
+ current_table = table_name
426
+ tables[table_name] ||= { columns: [], indexes: [], foreign_keys: [] }
427
+ # create_table implicitly adds id and timestamps in some cases
428
+ elsif stripped.match?(/\A\s*end\b/) && current_table
429
+ current_table = nil
430
+
431
+ # t.references / t.belongs_to inside create_table (must be before general column match)
432
+ elsif current_table && (match = stripped.match(/t\.(?:references|belongs_to)\s+[:"'](\w+)/))
433
+ ref_name = match[1]
434
+ col = { name: "#{ref_name}_id", type: "bigint" }
435
+ col[:null] = false if stripped.include?("null: false")
436
+ tables[current_table][:columns] << col
437
+
438
+ # t.timestamps inside create_table
439
+ elsif current_table && stripped.match?(/t\.timestamps/)
440
+ tables[current_table][:columns] << { name: "created_at", type: "datetime", null: false }
441
+ tables[current_table][:columns] << { name: "updated_at", type: "datetime", null: false }
442
+
443
+ # t.index inside create_table
444
+ elsif current_table && (match = stripped.match(/t\.index\s+\[([^\]]*)\]/))
445
+ cols = match[1].scan(/[:"'](\w+)/).flatten
446
+ unique = stripped.include?("unique: true")
447
+ idx_name = stripped.match(/name:\s*[:"']([^"'\s,]+)/)&.send(:[], 1)
448
+ tables[current_table][:indexes] << { name: idx_name, columns: cols, unique: unique }.compact if cols.any?
449
+
450
+ # t.type :name / t.type "name" (general column match inside create_table block)
451
+ elsif current_table && (match = stripped.match(/t\.(\w+)\s+[:"'](\w+)/))
452
+ col_type = match[1]
453
+ col_name = match[2]
454
+ next if %w[index check_constraint].include?(col_type)
455
+ col = { name: col_name, type: col_type }
456
+ col[:null] = false if stripped.include?("null: false")
457
+ if (def_match = stripped.match(/default:\s*("[^"]*"|\d+(?:\.\d+)?|true|false)/))
458
+ raw = def_match[1]
459
+ col[:default] = raw.start_with?('"') ? raw[1..-2] : raw
460
+ end
461
+ col[:array] = true if stripped.include?("array: true")
462
+ tables[current_table][:columns] << col
463
+
464
+ # add_column :table, :column, :type
465
+ elsif (match = stripped.match(/add_column\s+[:"'](\w+)['"']?,\s*[:"'](\w+)['"']?,\s*[:"'](\w+)/))
466
+ table_name, col_name, col_type = match[1], match[2], match[3]
467
+ if tables[table_name]
468
+ tables[table_name][:columns].reject! { |c| c[:name] == col_name }
469
+ col = { name: col_name, type: col_type }
470
+ col[:null] = false if stripped.include?("null: false")
471
+ if (def_match = stripped.match(/default:\s*("[^"]*"|\d+(?:\.\d+)?|true|false)/))
472
+ raw = def_match[1]
473
+ col[:default] = raw.start_with?('"') ? raw[1..-2] : raw
474
+ end
475
+ tables[table_name][:columns] << col
476
+ end
477
+
478
+ # remove_column :table, :column
479
+ elsif (match = stripped.match(/remove_column\s+[:"'](\w+)['"']?,\s*[:"'](\w+)/))
480
+ table_name, col_name = match[1], match[2]
481
+ tables[table_name][:columns]&.reject! { |c| c[:name] == col_name } if tables[table_name]
482
+
483
+ # rename_column :table, :old, :new
484
+ elsif (match = stripped.match(/rename_column\s+[:"'](\w+)['"']?,\s*[:"'](\w+)['"']?,\s*[:"'](\w+)/))
485
+ table_name, old_name, new_name = match[1], match[2], match[3]
486
+ if tables[table_name]
487
+ col = tables[table_name][:columns].find { |c| c[:name] == old_name }
488
+ col[:name] = new_name if col
489
+ end
490
+
491
+ # change_column :table, :column, :new_type
492
+ elsif (match = stripped.match(/change_column\s+[:"'](\w+)['"']?,\s*[:"'](\w+)['"']?,\s*[:"'](\w+)/))
493
+ table_name, col_name, new_type = match[1], match[2], match[3]
494
+ if tables[table_name]
495
+ col = tables[table_name][:columns].find { |c| c[:name] == col_name }
496
+ col[:type] = new_type if col
497
+ end
498
+
499
+ # rename_table :old, :new
500
+ elsif (match = stripped.match(/rename_table\s+[:"'](\w+)['"']?,\s*[:"'](\w+)/))
501
+ old_name, new_name = match[1], match[2]
502
+ tables[new_name] = tables.delete(old_name) if tables[old_name]
503
+
504
+ # drop_table :name
505
+ elsif (match = stripped.match(/drop_table\s+[:"'](\w+)/))
506
+ tables.delete(match[1])
507
+
508
+ # add_reference / add_belongs_to :table, :ref
509
+ elsif (match = stripped.match(/add_(?:reference|belongs_to)\s+[:"'](\w+)['"']?,\s*[:"'](\w+)/))
510
+ table_name, ref_name = match[1], match[2]
511
+ if tables[table_name]
512
+ col_name = "#{ref_name}_id"
513
+ tables[table_name][:columns].reject! { |c| c[:name] == col_name }
514
+ col = { name: col_name, type: "bigint" }
515
+ col[:null] = false if stripped.include?("null: false")
516
+ tables[table_name][:columns] << col
517
+ end
518
+
519
+ # add_index :table, [:cols]
520
+ elsif (match = stripped.match(/add_index\s+[:"'](\w+)['"']?,\s*\[([^\]]*)\]/))
521
+ table_name = match[1]
522
+ cols = match[2].scan(/[:"'](\w+)/).flatten
523
+ unique = stripped.include?("unique: true")
524
+ idx_name = stripped.match(/name:\s*[:"']([^"'\s,]+)/)&.send(:[], 1)
525
+ tables[table_name][:indexes]&.push({ name: idx_name, columns: cols, unique: unique }.compact) if tables[table_name] && cols.any?
526
+
527
+ # add_index :table, :single_col
528
+ elsif (match = stripped.match(/add_index\s+[:"'](\w+)['"']?,\s*[:"'](\w+)/))
529
+ table_name, col_name = match[1], match[2]
530
+ unique = stripped.include?("unique: true")
531
+ idx_name = stripped.match(/name:\s*[:"']([^"'\s,]+)/)&.send(:[], 1)
532
+ tables[table_name][:indexes]&.push({ name: idx_name, columns: [ col_name ], unique: unique }.compact) if tables[table_name]
533
+
534
+ # add_foreign_key :from, :to
535
+ elsif (match = stripped.match(/add_foreign_key\s+[:"'](\w+)['"']?,\s*[:"'](\w+)/))
536
+ from_table, to_table = match[1], match[2]
537
+ col_match = stripped.match(/column:\s*[:"'](\w+)/)
538
+ column = col_match ? col_match[1] : "#{to_table.chomp('s')}_id"
539
+ if tables[from_table]
540
+ tables[from_table][:foreign_keys] << { from_table: from_table, to_table: to_table, column: column, primary_key: "id" }
541
+ end
542
+
543
+ # add_timestamps :table
544
+ elsif (match = stripped.match(/add_timestamps\s+[:"'](\w+)/))
545
+ table_name = match[1]
546
+ if tables[table_name]
547
+ tables[table_name][:columns] << { name: "created_at", type: "datetime", null: false }
548
+ tables[table_name][:columns] << { name: "updated_at", type: "datetime", null: false }
549
+ end
550
+ end
551
+ end
552
+ end
553
+
377
554
  def normalize_sql_type(type)
378
555
  case type
379
556
  when /\Ainteger\z/i, /\Aint\z/i, /\Aint4\z/i then "integer"
@@ -140,7 +140,7 @@ module RailsAiContext
140
140
  return {} unless Dir.exist?(views_dir)
141
141
 
142
142
  counts = Hash.new(0)
143
- view_files = Dir.glob(File.join(views_dir, "**/*.{erb,haml,slim}"))
143
+ view_files = Dir.glob(File.join(views_dir, "**/*.{erb,haml,slim,rb}"))
144
144
  view_files.each do |path|
145
145
  content = File.read(path) rescue next
146
146
  FORM_BUILDER_PATTERNS.each do |name, pattern|
@@ -158,7 +158,7 @@ module RailsAiContext
158
158
  return [] unless Dir.exist?(views_dir)
159
159
 
160
160
  components = Set.new
161
- view_files = Dir.glob(File.join(views_dir, "**/*.{erb,haml,slim}"))
161
+ view_files = Dir.glob(File.join(views_dir, "**/*.{erb,haml,slim,rb}"))
162
162
  view_files.each do |path|
163
163
  content = File.read(path) rescue next
164
164
  # Match render ComponentName.new(...) or render(ComponentName.new(...))
@@ -40,11 +40,23 @@ module RailsAiContext
40
40
 
41
41
  relative = path.sub("#{views_dir}/", "")
42
42
  content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
43
- templates[relative] = {
44
- lines: content.lines.count,
45
- partials: extract_partial_refs(content),
46
- stimulus: extract_stimulus_refs(content)
47
- }
43
+
44
+ if phlex_view?(path, content)
45
+ templates[relative] = {
46
+ lines: content.lines.count,
47
+ partials: extract_partial_refs(content),
48
+ stimulus: extract_stimulus_refs(content),
49
+ components: extract_phlex_component_renders(content),
50
+ helpers: extract_phlex_helper_calls(content),
51
+ phlex: true
52
+ }
53
+ else
54
+ templates[relative] = {
55
+ lines: content.lines.count,
56
+ partials: extract_partial_refs(content),
57
+ stimulus: extract_stimulus_refs(content)
58
+ }
59
+ end
48
60
  end
49
61
  templates
50
62
  end
@@ -68,7 +80,7 @@ module RailsAiContext
68
80
  max_total = RailsAiContext.configuration.max_view_total_size
69
81
  max_single = RailsAiContext.configuration.max_view_file_size
70
82
  content = +""
71
- Dir.glob(File.join(views_dir, "**", "*.{erb,haml,slim}")).each do |path|
83
+ Dir.glob(File.join(views_dir, "**", "*.{erb,haml,slim,rb}")).each do |path|
72
84
  next if File.directory?(path)
73
85
  next if File.size(path) > max_single
74
86
  break if content.bytesize >= max_total
@@ -704,6 +716,41 @@ module RailsAiContext
704
716
  "Shared partial (#{content.lines.size} lines)"
705
717
  end
706
718
 
719
+ # Detect whether a view file is a Phlex view (Ruby DSL, not ERB)
720
+ def phlex_view?(path, content)
721
+ return false unless path.end_with?(".rb")
722
+ # Check for Phlex class patterns: inherits from a View/Base class and defines view_template
723
+ content.match?(/class\s+\S+\s*<\s*\S+/) && content.match?(/def\s+view_template\b/)
724
+ end
725
+
726
+ # Extract component render calls from Phlex Ruby DSL
727
+ # Matches: render ComponentName.new(...), render(ComponentName.new(...))
728
+ # Also matches: render Components::Nested::Name.new(...)
729
+ def extract_phlex_component_renders(content)
730
+ components = Set.new
731
+ content.scan(/render[\s(]+([A-Z]\w+(?:::\w+)*)\.new/).each do |match|
732
+ components << match[0]
733
+ end
734
+ components.to_a.sort
735
+ end
736
+
737
+ # Extract helper method calls from Phlex views
738
+ # Phlex views use include to pull in helpers, and call them directly
739
+ PHLEX_HELPER_METHODS = %w[
740
+ link_to image_tag content_for button_to form_with form_for
741
+ content_tag tag number_to_currency number_to_human
742
+ time_ago_in_words distance_of_time_in_words
743
+ truncate pluralize raw sanitize dom_id
744
+ ].freeze
745
+
746
+ def extract_phlex_helper_calls(content)
747
+ helpers = []
748
+ PHLEX_HELPER_METHODS.each do |method|
749
+ helpers << method if content.match?(/\b#{method}\b/)
750
+ end
751
+ helpers
752
+ end
753
+
707
754
  EXCLUDED_METHODS = %w[
708
755
  each map select reject first last size count any? empty? present? blank?
709
756
  new build create find where order limit nil? join class html_safe
@@ -750,19 +797,25 @@ module RailsAiContext
750
797
  content.scan(/render\s+(?:partial:\s*)?["']([^"']+)["']/).each { |m| refs << m[0] }
751
798
  # render @collection
752
799
  content.scan(/render\s+@(\w+)/).each { |m| refs << m[0] }
800
+ # Phlex: render ComponentName.new(...) or render(ComponentName.new(...))
801
+ content.scan(/render[\s(]+([A-Z]\w+(?:::\w+)*)\.new/).each { |m| refs << m[0] }
753
802
  refs.uniq
754
803
  end
755
804
 
756
805
  def extract_stimulus_refs(content)
757
806
  refs = []
758
- # data-controller="name" or data-controller="name1 name2"
807
+ # data-controller="name" or data-controller="name1 name2" (ERB/HTML)
759
808
  content.scan(/data-controller=["']([^"']+)["']/).each do |m|
760
809
  m[0].split.each { |c| refs << c }
761
810
  end
762
- # data: { controller: "name" }
811
+ # data: { controller: "name" } (ERB helpers / Phlex hash syntax)
763
812
  content.scan(/controller:\s*["']([^"']+)["']/).each do |m|
764
813
  m[0].split.each { |c| refs << c }
765
814
  end
815
+ # Phlex keyword: data_controller: "name" (Phlex HTML attributes)
816
+ content.scan(/data_controller:\s*["']([^"']+)["']/).each do |m|
817
+ m[0].split.each { |c| refs << c }
818
+ end
766
819
  refs.uniq
767
820
  end
768
821
  end
@@ -59,6 +59,7 @@ module RailsAiContext
59
59
  ""
60
60
  ]
61
61
 
62
+ # Compact counts — gems and architecture are already in the root file (CLAUDE.md/AGENTS.md)
62
63
  schema = context[:schema]
63
64
  if schema.is_a?(Hash) && !schema[:error]
64
65
  lines << "- Database: #{schema[:adapter]} — #{schema[:total_tables]} tables"
@@ -70,19 +71,6 @@ module RailsAiContext
70
71
  routes = context[:routes]
71
72
  lines << "- Routes: #{routes[:total_routes]}" if routes.is_a?(Hash) && !routes[:error]
72
73
 
73
- gems = context[:gems]
74
- if gems.is_a?(Hash) && !gems[:error]
75
- notable = gems[:notable_gems] || []
76
- notable.group_by { |g| g[:category]&.to_s || "other" }.first(6).each do |cat, gem_list|
77
- lines << "- #{cat}: #{gem_list.map { |g| g[:name] }.join(', ')}"
78
- end
79
- end
80
-
81
- conv = context[:conventions]
82
- if conv.is_a?(Hash) && !conv[:error]
83
- (conv[:architecture] || []).first(5).each { |p| lines << "- #{p}" }
84
- end
85
-
86
74
  lines.concat(full_preset_stack_lines)
87
75
 
88
76
  # ApplicationController before_actions — apply to all controllers
@@ -96,7 +84,7 @@ module RailsAiContext
96
84
  lines << "" << "**Global before_actions:** #{before_actions.join(', ')}"
97
85
  end
98
86
  end
99
- rescue; end
87
+ rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
100
88
 
101
89
  lines << ""
102
90
  lines << "ALWAYS use MCP tools for context — do NOT read reference files directly."
@@ -114,8 +102,7 @@ module RailsAiContext
114
102
  lines = [
115
103
  "# Database Tables (#{tables.size})",
116
104
  "",
117
- "All columns with types are listed below no need to read db/schema.rb.",
118
- "For indexes, foreign keys, or constraints, use `rails_get_schema(table:\"name\")`.",
105
+ "_Snapshot may be stale after migrations. Use `rails_get_schema(table:\"name\")` for live data._",
119
106
  ""
120
107
  ]
121
108
 
@@ -195,8 +182,7 @@ module RailsAiContext
195
182
  lines = [
196
183
  "# ActiveRecord Models (#{models.size})",
197
184
  "",
198
- "Check this file first for associations, scopes, constants, and validations.",
199
- "If you need more detail (callbacks, methods, business logic), use `rails_get_model_details(model:\"Name\")` or Read the file directly.",
185
+ "_Quick reference use `rails_get_model_details(model:\"Name\")` for live data with resolved concerns and callbacks._",
200
186
  ""
201
187
  ]
202
188
 
@@ -223,8 +209,13 @@ module RailsAiContext
223
209
  scope_names = scopes.map { |s| s.is_a?(Hash) ? s[:name] : s }
224
210
  lines << " scopes: #{scope_names.join(', ')}" if scopes.any?
225
211
 
226
- # Instance methods — introspector already prioritizes source-defined and filters Devise
227
- methods = (data[:instance_methods] || []).reject { |m| m.end_with?("=") }.first(20)
212
+ # Instance methods — filter Devise/framework internals that add noise
213
+ devise_noise = %w[after_remembered apply_to_attribute_or_variable clear_reset_password_token
214
+ clear_reset_password_token? current_password devise_modules devise_modules?
215
+ devise_respond_to_and_will_save_change_to_attribute?]
216
+ methods = (data[:instance_methods] || [])
217
+ .reject { |m| m.end_with?("=") || devise_noise.include?(m) }
218
+ .first(20)
228
219
  lines << " methods: #{methods.join(', ')}" if methods.any?
229
220
 
230
221
  # Include constants (e.g. STATUSES, MODES) so agents know valid values
@@ -268,7 +259,7 @@ module RailsAiContext
268
259
  partials.each { |p| lines << "- #{p}" }
269
260
  end
270
261
  end
271
- rescue; end
262
+ rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
272
263
 
273
264
  # Helpers — so agents use existing helpers instead of creating new ones
274
265
  begin
@@ -281,7 +272,7 @@ module RailsAiContext
281
272
  lines << helper_methods.map { |m| "- #{m}" }.join("\n")
282
273
  end
283
274
  end
284
- rescue; end
275
+ rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
285
276
 
286
277
  # Stimulus controllers — so agents reuse existing controllers
287
278
  stim = context[:stimulus]
@@ -37,7 +37,6 @@ module RailsAiContext
37
37
  lines.concat(render_ui_patterns)
38
38
  lines.concat(render_commands)
39
39
  lines.concat(render_footer)
40
- lines.concat(render_conventions)
41
40
  lines.concat(render_mcp_guide_compact)
42
41
 
43
42
  # Enforce max lines
@@ -177,7 +176,7 @@ module RailsAiContext
177
176
  .map { |f| File.basename(f, ".rb").camelize }
178
177
  .reject { |s| s == "ApplicationService" }
179
178
  lines << "" << "**Services:** #{service_files.join(', ')}" if service_files.any?
180
- rescue; end
179
+ rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
181
180
  end
182
181
 
183
182
  if dir_struct["app/jobs"]
@@ -187,7 +186,7 @@ module RailsAiContext
187
186
  .map { |f| File.basename(f, ".rb").camelize }
188
187
  .reject { |j| j == "ApplicationJob" }
189
188
  lines << "**Jobs:** #{job_files.join(', ')}" if job_files.any?
190
- rescue; end
189
+ rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
191
190
  end
192
191
 
193
192
  lines << ""
@@ -253,6 +252,19 @@ module RailsAiContext
253
252
  lines << "- Stimulus controllers auto-register — no manual import in controllers/index.js needed"
254
253
  end
255
254
 
255
+ # Global before_actions — critical for understanding controller flow
256
+ begin
257
+ root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
258
+ app_ctrl_file = File.join(root, "app", "controllers", "application_controller.rb")
259
+ if File.exist?(app_ctrl_file)
260
+ source = File.read(app_ctrl_file)
261
+ before_actions = source.scan(/before_action\s+:([\w!?]+)/).flatten
262
+ if before_actions.any?
263
+ lines << "- Global before_actions: #{before_actions.join(', ')}"
264
+ end
265
+ end
266
+ rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
267
+
256
268
  lines << ""
257
269
  lines
258
270
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "securerandom"
5
+
3
6
  module RailsAiContext
4
7
  module Serializers
5
8
  # Orchestrates writing context files to disk in various formats.
@@ -87,7 +90,7 @@ module RailsAiContext
87
90
  if File.exist?(filepath) && File.read(filepath) == content
88
91
  skipped << filepath
89
92
  else
90
- File.write(filepath, content)
93
+ atomic_write(filepath, content)
91
94
  written << filepath
92
95
  end
93
96
  end
@@ -112,15 +115,24 @@ module RailsAiContext
112
115
  if new_content == existing
113
116
  skipped << filepath
114
117
  else
115
- File.write(filepath, new_content)
118
+ atomic_write(filepath, new_content)
116
119
  written << filepath
117
120
  end
118
121
  else
119
- File.write(filepath, marked_content)
122
+ atomic_write(filepath, marked_content)
120
123
  written << filepath
121
124
  end
122
125
  end
123
126
 
127
+ # Write via temp file + rename to avoid partial writes from concurrent processes
128
+ def atomic_write(filepath, content)
129
+ dir = File.dirname(filepath)
130
+ FileUtils.mkdir_p(dir)
131
+ tmp = File.join(dir, ".#{File.basename(filepath)}.#{SecureRandom.hex(4)}.tmp")
132
+ File.write(tmp, content)
133
+ File.rename(tmp, filepath)
134
+ end
135
+
124
136
  def generate_split_rules(formats, output_dir, written, skipped)
125
137
  if formats.include?(:claude)
126
138
  result = ClaudeRulesSerializer.new(context).call(output_dir)
@@ -95,7 +95,7 @@ module RailsAiContext
95
95
  .reject { |s| s == "ApplicationService" }
96
96
  lines << "- Services: #{service_files.join(', ')}" if service_files.any?
97
97
  end
98
- rescue; end
98
+ rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
99
99
 
100
100
  # List jobs
101
101
  begin
@@ -107,7 +107,7 @@ module RailsAiContext
107
107
  .reject { |j| j == "ApplicationJob" }
108
108
  lines << "- Jobs: #{job_files.join(', ')}" if job_files.any?
109
109
  end
110
- rescue; end
110
+ rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
111
111
 
112
112
  # ApplicationController before_actions
113
113
  begin
@@ -118,7 +118,7 @@ module RailsAiContext
118
118
  before_actions = source.scan(/before_action\s+:([\w!?]+)/).flatten
119
119
  lines << "" << "**Global before_actions:** #{before_actions.join(', ')}" if before_actions.any?
120
120
  end
121
- rescue; end
121
+ rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
122
122
 
123
123
  lines << ""
124
124
  lines << "Use MCP tools for detailed data. Start with `detail:\"summary\"`."
@@ -103,7 +103,7 @@ module RailsAiContext
103
103
  .reject { |s| s == "ApplicationService" }
104
104
  lines << "- Services: #{service_files.join(', ')}" if service_files.any?
105
105
  end
106
- rescue; end
106
+ rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
107
107
 
108
108
  # List jobs
109
109
  begin
@@ -115,7 +115,7 @@ module RailsAiContext
115
115
  .reject { |j| j == "ApplicationJob" }
116
116
  lines << "- Jobs: #{job_files.join(', ')}" if job_files.any?
117
117
  end
118
- rescue; end
118
+ rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
119
119
 
120
120
  # ApplicationController before_actions
121
121
  begin
@@ -126,7 +126,7 @@ module RailsAiContext
126
126
  before_actions = source.scan(/before_action\s+:([\w!?]+)/).flatten
127
127
  lines << "" << "Global before_actions: #{before_actions.join(', ')}" if before_actions.any?
128
128
  end
129
- rescue; end
129
+ rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
130
130
 
131
131
  lines << ""
132
132
  lines << "MCP tools available — see rails-mcp-tools.mdc for full reference."
@@ -235,7 +235,7 @@ module RailsAiContext
235
235
  partials.each { |p| lines << "- #{p}" }
236
236
  end
237
237
  end
238
- rescue; end
238
+ rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
239
239
 
240
240
  # Stimulus controllers
241
241
  stim = context[:stimulus]
@@ -255,7 +255,7 @@ module RailsAiContext
255
255
  def render_mcp_tools_rule
256
256
  lines = [
257
257
  "---",
258
- "description: \"Rails tools (37) — MANDATORY, use before reading any reference files\"",
258
+ "description: \"Rails tools (39) — MANDATORY, use before reading any reference files\"",
259
259
  "alwaysApply: true",
260
260
  "---",
261
261
  ""
@@ -52,7 +52,9 @@ module RailsAiContext
52
52
  lines << "## Page Examples — Copy These Patterns"
53
53
  lines << ""
54
54
 
55
- examples.each do |ex|
55
+ # Cap at 3 examples to avoid bloating context files
56
+ shown = examples.first(3)
57
+ shown.each do |ex|
56
58
  label = { form_page: "Form Page", list_page: "List/Grid Page", show_page: "Detail Page",
57
59
  dashboard: "Dashboard" }[ex[:type]] || ex[:type].to_s.tr("_", " ").capitalize
58
60
  lines << "### #{label} (#{ex[:template]})"
@@ -62,6 +64,11 @@ module RailsAiContext
62
64
  lines << "```"
63
65
  lines << ""
64
66
  end
67
+
68
+ if examples.size > 3
69
+ lines << "_#{examples.size - 3} more examples available via `get_design_system(detail:\"full\")` tool._"
70
+ lines << ""
71
+ end
65
72
  end
66
73
 
67
74
  # Responsive patterns
@@ -115,7 +115,7 @@ module RailsAiContext
115
115
  before_actions = source.scan(/before_action\s+:([\w!?]+)/).flatten
116
116
  lines << "**Global before_actions:** #{before_actions.join(', ')}" << "" if before_actions.any?
117
117
  end
118
- rescue; end
118
+ rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
119
119
 
120
120
  app_controllers.keys.sort.first(25).each do |name|
121
121
  info = app_controllers[name]
@@ -137,7 +137,7 @@ module RailsAiContext
137
137
  .reject { |s| s == "ApplicationService" }
138
138
  lines << "" << "**Services:** #{service_files.join(', ')}" if service_files.any?
139
139
  end
140
- rescue; end
140
+ rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
141
141
 
142
142
  # List jobs
143
143
  begin
@@ -149,7 +149,7 @@ module RailsAiContext
149
149
  .reject { |j| j == "ApplicationJob" }
150
150
  lines << "**Jobs:** #{job_files.join(', ')}" if job_files.any?
151
151
  end
152
- rescue; end
152
+ rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
153
153
 
154
154
  lines << ""
155
155
  lines << "Use `rails_get_controllers(controller:\"Name\", action:\"index\")` for one action's source code."
@@ -36,7 +36,6 @@ module RailsAiContext
36
36
  lines.concat(render_architecture)
37
37
  lines.concat(render_ui_patterns)
38
38
  lines.concat(render_commands)
39
- lines.concat(render_conventions)
40
39
  lines.concat(render_mcp_guide_compact)
41
40
  lines.concat(render_footer)
42
41