rails-ai-context 4.2.3 → 4.3.1

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  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 +18 -10
  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 +10 -19
  18. data/lib/rails_ai_context/serializers/claude_serializer.rb +42 -11
  19. data/lib/rails_ai_context/serializers/context_file_serializer.rb +14 -3
  20. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +1 -1
  21. data/lib/rails_ai_context/serializers/design_system_helper.rb +8 -1
  22. data/lib/rails_ai_context/serializers/opencode_serializer.rb +5 -2
  23. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +165 -64
  24. data/lib/rails_ai_context/server.rb +12 -1
  25. data/lib/rails_ai_context/tools/base_tool.rb +63 -1
  26. data/lib/rails_ai_context/tools/diagnose.rb +436 -0
  27. data/lib/rails_ai_context/tools/generate_test.rb +571 -0
  28. data/lib/rails_ai_context/tools/get_callbacks.rb +27 -4
  29. data/lib/rails_ai_context/tools/get_component_catalog.rb +11 -2
  30. data/lib/rails_ai_context/tools/get_context.rb +70 -8
  31. data/lib/rails_ai_context/tools/get_conventions.rb +59 -0
  32. data/lib/rails_ai_context/tools/get_design_system.rb +45 -7
  33. data/lib/rails_ai_context/tools/get_edit_context.rb +3 -2
  34. data/lib/rails_ai_context/tools/get_env.rb +51 -24
  35. data/lib/rails_ai_context/tools/get_frontend_stack.rb +100 -9
  36. data/lib/rails_ai_context/tools/get_model_details.rb +19 -0
  37. data/lib/rails_ai_context/tools/get_partial_interface.rb +1 -1
  38. data/lib/rails_ai_context/tools/get_stimulus.rb +13 -7
  39. data/lib/rails_ai_context/tools/get_turbo_map.rb +35 -2
  40. data/lib/rails_ai_context/tools/get_view.rb +65 -9
  41. data/lib/rails_ai_context/tools/migration_advisor.rb +4 -0
  42. data/lib/rails_ai_context/tools/onboard.rb +755 -0
  43. data/lib/rails_ai_context/tools/query.rb +4 -2
  44. data/lib/rails_ai_context/tools/read_logs.rb +4 -1
  45. data/lib/rails_ai_context/tools/review_changes.rb +299 -0
  46. data/lib/rails_ai_context/tools/runtime_info.rb +289 -0
  47. data/lib/rails_ai_context/tools/search_code.rb +23 -4
  48. data/lib/rails_ai_context/tools/security_scan.rb +7 -1
  49. data/lib/rails_ai_context/tools/session_context.rb +132 -0
  50. data/lib/rails_ai_context/version.rb +1 -1
  51. metadata +10 -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
@@ -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
@@ -35,10 +35,9 @@ module RailsAiContext
35
35
  lines.concat(render_notable_gems)
36
36
  lines.concat(render_architecture)
37
37
  lines.concat(render_ui_patterns)
38
- lines.concat(render_mcp_guide)
39
- lines.concat(render_conventions)
40
38
  lines.concat(render_commands)
41
39
  lines.concat(render_footer)
40
+ lines.concat(render_mcp_guide_compact)
42
41
 
43
42
  # Enforce max lines
44
43
  max = RailsAiContext.configuration.claude_max_lines
@@ -202,6 +201,10 @@ module RailsAiContext
202
201
  render_tools_guide
203
202
  end
204
203
 
204
+ def render_mcp_guide_compact
205
+ render_tools_guide_compact
206
+ end
207
+
205
208
  def render_conventions
206
209
  conv = context[:conventions]
207
210
  return [] unless conv.is_a?(Hash) && !conv[:error]
@@ -227,15 +230,43 @@ module RailsAiContext
227
230
  end
228
231
 
229
232
  def render_footer
230
- [
231
- "## Rules",
232
- "- Follow existing patterns and conventions",
233
- "- Match existing code style",
234
- "- Run tests after changes",
235
- "- Do NOT re-read files to verify edits — trust your Edit, validate syntax only",
236
- "- Stimulus controllers auto-register — no manual import in controllers/index.js needed",
237
- ""
238
- ]
233
+ test_cmd = detect_test_command
234
+ lines = [ "## Rules" ]
235
+ lines << "- Run `#{test_cmd}` after changes"
236
+ lines << "- Do NOT re-read files to verify edits — trust your Edit, validate syntax only"
237
+
238
+ # App-specific conventions from introspection
239
+ conv = context[:conventions]
240
+ if conv.is_a?(Hash) && !conv[:error]
241
+ arch = conv[:architecture] || []
242
+ lines << "- Follow #{arch.join(' + ')} architecture" if arch.any?
243
+ patterns = conv[:patterns] || []
244
+ lines << "- Use service objects for business logic" if patterns.include?("service_objects")
245
+ lines << "- Use form objects for complex forms" if patterns.include?("form_objects")
246
+ lines << "- Use query objects for complex queries" if patterns.include?("query_objects")
247
+ end
248
+
249
+ # Stimulus auto-register if detected
250
+ stimulus = context[:stimulus]
251
+ if stimulus.is_a?(Hash) && !stimulus[:error] && (stimulus[:controllers]&.any? || stimulus[:total_controllers]&.positive?)
252
+ lines << "- Stimulus controllers auto-register — no manual import in controllers/index.js needed"
253
+ end
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; end
267
+
268
+ lines << ""
269
+ lines
239
270
  end
240
271
  end
241
272
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+
3
5
  module RailsAiContext
4
6
  module Serializers
5
7
  # Orchestrates writing context files to disk in various formats.
@@ -87,7 +89,7 @@ module RailsAiContext
87
89
  if File.exist?(filepath) && File.read(filepath) == content
88
90
  skipped << filepath
89
91
  else
90
- File.write(filepath, content)
92
+ atomic_write(filepath, content)
91
93
  written << filepath
92
94
  end
93
95
  end
@@ -112,15 +114,24 @@ module RailsAiContext
112
114
  if new_content == existing
113
115
  skipped << filepath
114
116
  else
115
- File.write(filepath, new_content)
117
+ atomic_write(filepath, new_content)
116
118
  written << filepath
117
119
  end
118
120
  else
119
- File.write(filepath, marked_content)
121
+ atomic_write(filepath, marked_content)
120
122
  written << filepath
121
123
  end
122
124
  end
123
125
 
126
+ # Write via temp file + rename to avoid partial writes from concurrent processes
127
+ def atomic_write(filepath, content)
128
+ dir = File.dirname(filepath)
129
+ FileUtils.mkdir_p(dir)
130
+ tmp = File.join(dir, ".#{File.basename(filepath)}.tmp")
131
+ File.write(tmp, content)
132
+ File.rename(tmp, filepath)
133
+ end
134
+
124
135
  def generate_split_rules(formats, output_dir, written, skipped)
125
136
  if formats.include?(:claude)
126
137
  result = ClaudeRulesSerializer.new(context).call(output_dir)
@@ -255,7 +255,7 @@ module RailsAiContext
255
255
  def render_mcp_tools_rule
256
256
  lines = [
257
257
  "---",
258
- "description: \"Rails tools (25) — 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
@@ -35,9 +35,8 @@ module RailsAiContext
35
35
  lines.concat(render_notable_gems)
36
36
  lines.concat(render_architecture)
37
37
  lines.concat(render_ui_patterns)
38
- lines.concat(render_mcp_guide)
39
- lines.concat(render_conventions)
40
38
  lines.concat(render_commands)
39
+ lines.concat(render_mcp_guide_compact)
41
40
  lines.concat(render_footer)
42
41
 
43
42
  # Enforce max lines
@@ -168,6 +167,10 @@ module RailsAiContext
168
167
  render_tools_guide
169
168
  end
170
169
 
170
+ def render_mcp_guide_compact
171
+ render_tools_guide_compact
172
+ end
173
+
171
174
  def render_conventions
172
175
  conv = context[:conventions]
173
176
  return [] unless conv.is_a?(Hash) && !conv[:error]