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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -8
- data/CLAUDE.md +4 -4
- data/CONTRIBUTING.md +1 -1
- data/README.md +7 -7
- data/SECURITY.md +2 -1
- data/docs/GUIDE.md +3 -3
- data/lib/generators/rails_ai_context/install/install_generator.rb +2 -2
- data/lib/rails_ai_context/configuration.rb +4 -2
- data/lib/rails_ai_context/doctor.rb +6 -1
- data/lib/rails_ai_context/fingerprinter.rb +24 -0
- data/lib/rails_ai_context/introspectors/component_introspector.rb +122 -7
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +31 -26
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +183 -6
- data/lib/rails_ai_context/introspectors/view_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +61 -8
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +13 -22
- data/lib/rails_ai_context/serializers/claude_serializer.rb +15 -3
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +15 -3
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +3 -3
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +5 -5
- data/lib/rails_ai_context/serializers/design_system_helper.rb +8 -1
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +3 -3
- data/lib/rails_ai_context/serializers/opencode_serializer.rb +0 -1
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +15 -9
- data/lib/rails_ai_context/server.rb +8 -1
- data/lib/rails_ai_context/tools/analyze_feature.rb +24 -1
- data/lib/rails_ai_context/tools/base_tool.rb +78 -1
- data/lib/rails_ai_context/tools/dependency_graph.rb +4 -1
- data/lib/rails_ai_context/tools/diagnose.rb +135 -8
- data/lib/rails_ai_context/tools/generate_test.rb +87 -7
- data/lib/rails_ai_context/tools/get_callbacks.rb +27 -4
- data/lib/rails_ai_context/tools/get_component_catalog.rb +11 -2
- data/lib/rails_ai_context/tools/get_context.rb +71 -8
- data/lib/rails_ai_context/tools/get_conventions.rb +59 -0
- data/lib/rails_ai_context/tools/get_design_system.rb +45 -7
- data/lib/rails_ai_context/tools/get_edit_context.rb +3 -2
- data/lib/rails_ai_context/tools/get_env.rb +51 -24
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +100 -9
- data/lib/rails_ai_context/tools/get_model_details.rb +20 -0
- data/lib/rails_ai_context/tools/get_partial_interface.rb +12 -5
- data/lib/rails_ai_context/tools/get_schema.rb +1 -0
- data/lib/rails_ai_context/tools/get_stimulus.rb +13 -7
- data/lib/rails_ai_context/tools/get_turbo_map.rb +35 -2
- data/lib/rails_ai_context/tools/get_view.rb +65 -9
- data/lib/rails_ai_context/tools/migration_advisor.rb +10 -3
- data/lib/rails_ai_context/tools/onboard.rb +413 -27
- data/lib/rails_ai_context/tools/performance_check.rb +45 -28
- data/lib/rails_ai_context/tools/query.rb +28 -2
- data/lib/rails_ai_context/tools/read_logs.rb +4 -1
- data/lib/rails_ai_context/tools/review_changes.rb +27 -17
- data/lib/rails_ai_context/tools/runtime_info.rb +289 -0
- data/lib/rails_ai_context/tools/search_code.rb +23 -4
- data/lib/rails_ai_context/tools/security_scan.rb +7 -1
- data/lib/rails_ai_context/tools/session_context.rb +137 -0
- data/lib/rails_ai_context/tools/validate.rb +5 -0
- data/lib/rails_ai_context/version.rb +1 -1
- 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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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 —
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
+
atomic_write(filepath, new_content)
|
|
116
119
|
written << filepath
|
|
117
120
|
end
|
|
118
121
|
else
|
|
119
|
-
|
|
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 (
|
|
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
|
|
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."
|