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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- 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 +18 -10
- 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 +10 -19
- data/lib/rails_ai_context/serializers/claude_serializer.rb +42 -11
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +14 -3
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/design_system_helper.rb +8 -1
- data/lib/rails_ai_context/serializers/opencode_serializer.rb +5 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +165 -64
- data/lib/rails_ai_context/server.rb +12 -1
- data/lib/rails_ai_context/tools/base_tool.rb +63 -1
- data/lib/rails_ai_context/tools/diagnose.rb +436 -0
- data/lib/rails_ai_context/tools/generate_test.rb +571 -0
- 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 +70 -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 +19 -0
- data/lib/rails_ai_context/tools/get_partial_interface.rb +1 -1
- 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 +4 -0
- data/lib/rails_ai_context/tools/onboard.rb +755 -0
- data/lib/rails_ai_context/tools/query.rb +4 -2
- data/lib/rails_ai_context/tools/read_logs.rb +4 -1
- data/lib/rails_ai_context/tools/review_changes.rb +299 -0
- 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 +132 -0
- data/lib/rails_ai_context/version.rb +1 -1
- 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
|
-
|
|
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
|
|
@@ -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
|
|
@@ -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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
+
atomic_write(filepath, new_content)
|
|
116
118
|
written << filepath
|
|
117
119
|
end
|
|
118
120
|
else
|
|
119
|
-
|
|
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 (
|
|
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
|
|
@@ -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]
|