rails-ai-context 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41d6a7f273bd4630ef25d299a4379609336714a021d77bcbb5128235a62bd830
4
- data.tar.gz: 64b3c29bbf29126d6f8aaf76e1f063140ad7ea3684e15ca392a2275d2fe35110
3
+ metadata.gz: bdd226d739429a34c2ea8cedc8faf12e854d0220de60f5ffb08fe8fed5459f71
4
+ data.tar.gz: 7b609ca0007e97206b168cbda0937fb9fb581888491464619ba727a8a1b2d945
5
5
  SHA512:
6
- metadata.gz: 90003df73a6d12bec5e205343ffc4615a6f59061f32bdba71f344dfcacf046de339836b871d1e6e85353bff3951673a5fa2f2c2b0bd3b950c9b0d62872760bc2
7
- data.tar.gz: eaad1515c31ee3d6d3cb61a402991d92aa6107770011d70338a096d8dc23a809423e77ba3194d7813cbb3df2ea3cadf772ebdf4f7b939afe0c82940471b7b7a3
6
+ metadata.gz: b54b6354d0236f8f46f813593a5472fcca35dd4ceb1aefc4631479829c541b53d8a34509602b2f5903ff1af4b34b3d43d758e41f2c8e84cb5498b3212011541a
7
+ data.tar.gz: 0ddd8d1b1d413c3313a3189168c790dbbe776de2d573be006163867732287e939c0c8ea4490b39d1b86239f6940cfd923fcfab27ce24f6d9ba9bc3cda0c750fb
data/CLAUDE.md CHANGED
@@ -42,7 +42,7 @@ structure to AI assistants via the Model Context Protocol (MCP).
42
42
  ## Testing
43
43
 
44
44
  ```bash
45
- bundle exec rspec # Run specs (456 examples)
45
+ bundle exec rspec # Run specs (468 examples)
46
46
  bundle exec rubocop # Lint
47
47
  ```
48
48
 
data/README.md CHANGED
@@ -21,22 +21,49 @@ The AI doesn't know your schema, your Devise setup, your Sidekiq jobs, or that `
21
21
 
22
22
  ---
23
23
 
24
- ## Proof: 35% Token Savings (Real Benchmark)
24
+ ## Proof: 37% Token Savings (Real Benchmark)
25
25
 
26
- We ran the same feature task — *"Add status and date range filters to the Cooks index page"* — across 4 scenarios in parallel on a real Rails app:
26
+ Same task — *"Add status and date range filters to the Cooks index page"* — 4 scenarios in parallel, same Rails app:
27
27
 
28
- | Scenario | MCP Tools | CLAUDE.md | Tokens Used | Savings |
29
- |----------|-----------|-----------|-------------|---------|
30
- | **MCP + CLAUDE.md** | Yes | Yes | **25,884** | **35% saved** |
31
- | MCP only | Yes | No | 27,822 | 31% saved |
32
- | CLAUDE.md only | No | Yes | 32,699 | 19% saved |
33
- | Zero (nothing) | No | No | 40,129 | baseline |
28
+ | Setup | Tokens | Saved | What it knows |
29
+ |-------|--------|-------|---------------|
30
+ | **rails-ai-context (full)** | **28,834** | **37%** | 11 MCP tools + generated docs + rules |
31
+ | rails-ai-context CLAUDE.md only | 33,106 | 27% | Generated docs + rules, no MCP tools |
32
+ | Normal Claude `/init` | 40,700 | 11% | Generic CLAUDE.md only |
33
+ | No rails-ai-context at all | 45,477 | baseline | Nothing — discovers everything from scratch |
34
34
 
35
- All 4 produced the same working feature. The only difference was how many tokens were burned getting there.
35
+ ```
36
+ No rails-ai-context 45,477 tk █████████████████████████████████████████████
37
+ Normal Claude /init 40,700 tk █████████████████████████████████████████ -11%
38
+ rails-ai-context CLAUDE.md 33,106 tk █████████████████████████████████ -27%
39
+ rails-ai-context (full) 28,834 tk █████████████████████████████ -37%
40
+ ```
41
+
42
+ https://github.com/user-attachments/assets/14476243-1210-4e62-9dc5-9d4aa9caef7e
43
+
44
+
45
+ **What each layer gives you:**
46
+
47
+ | | Normal `/init` | rails-ai-context CLAUDE.md | rails-ai-context full |
48
+ |---|---|---|---|
49
+ | Knows it's Rails + Tailwind | Yes | Yes | Yes |
50
+ | Knows model names, columns, associations | No | Yes | Yes |
51
+ | Knows controller actions, filters | No | Yes | Yes |
52
+ | Discovery overhead | ~8 calls | 0 calls | 0 calls |
53
+ | Structured MCP queries | No | No | Yes — 5 MCP calls replace file reads |
54
+
55
+ **~16,600 fewer tokens per task** vs no gem at all.
56
+
57
+ > **This was a simple task on a small 5-model app.** Real-world tasks are 3-10x more complex.
58
+ > A feature touching auth + payments + mailers + tests on a 50-model app? Without the gem, Claude reads `db/schema.rb` (2,000+ lines), every model file, every controller, every view — easily 200K+ tokens per session. With rails-ai-context, MCP tools return only what's needed: `rails_get_schema(table:"users")` returns 25 lines instead of 2,000. **The bigger your app and the harder the task, the more you save.**
36
59
 
37
- https://github.com/user-attachments/assets/171f52ae-bd30-43f6-a44f-bcfdda7fc139
60
+ | App size | Without gem | With rails-ai-context | Savings |
61
+ |----------|-------------|----------------------|---------|
62
+ | Small (5 models) | 45K tokens | 29K tokens | 37% |
63
+ | Medium (30 models) | ~150K tokens | ~60K tokens | ~60% |
64
+ | Large (100+ models) | ~500K+ tokens | ~100K tokens | ~80% |
38
65
 
39
- MCP tools give the AI structured, filtered access to your codebase instead of reading entire files. On this small 5-model app, that saved 35%. **On larger projects, the savings compound significantly.**
66
+ *Medium/large estimates based on schema.rb scaling (40 lines/table), model file scaling, and MCP summary-first workflow eliminating full-file reads.*
40
67
 
41
68
  ---
42
69
 
@@ -60,10 +87,10 @@ The install generator creates `.mcp.json` for auto-discovery — Claude Code and
60
87
 
61
88
  ![Token Comparison](https://raw.githubusercontent.com/crisnahine/rails-ai-context/main/docs/token-comparison.jpeg)
62
89
 
63
- - Compact context files load ≤150 lines instead of thousands
64
- - MCP tools return `detail:"summary"` first (~55 tokens for schema overview), then drill into specifics
65
- - Specific lookups (`table:`, `model:`, `controller:`) return only what's needed
66
- - Pagination prevents dumping hundreds of tables/routes at once
90
+ - `/init` saves 11% knows the framework but wastes tokens discovering models and tables
91
+ - **CLAUDE.md saves 27%** complete Rails-specific map, zero discovery overhead
92
+ - **Full MCP saves 37%** structured queries replace expensive full-file reads
93
+ - MCP tools return `detail:"summary"` first (~55 tokens), then drill into specifics
67
94
  - Split rule files only activate in relevant directories
68
95
 
69
96
  ---
@@ -362,7 +389,7 @@ The gem parses `db/schema.rb` as text when no database is connected. Works in CI
362
389
  ```bash
363
390
  git clone https://github.com/crisnahine/rails-ai-context.git
364
391
  cd rails-ai-context && bundle install
365
- bundle exec rspec # 456 examples
392
+ bundle exec rspec # 468 examples
366
393
  bundle exec rubocop # Lint
367
394
  ```
368
395
 
data/demo_script.sh CHANGED
@@ -8,7 +8,7 @@ echo 'Fetching gem metadata from https://rubygems.org...'
8
8
  sleep 0.3
9
9
  echo 'Resolving dependencies...'
10
10
  sleep 0.3
11
- echo 'Installing rails-ai-context 0.10.1'
11
+ echo 'Installing rails-ai-context 0.11.0'
12
12
  echo ''
13
13
  sleep 1
14
14
 
@@ -169,8 +169,8 @@ module RailsAiContext
169
169
  macros = {}
170
170
 
171
171
  macros[:has_secure_password] = true if source.match?(/\bhas_secure_password\b/)
172
- macros[:encrypts] = source.scan(/\bencrypts\s+(.+)/).flat_map { |m| m[0].scan(/:(\w+)/).flatten } if source.match?(/\bencrypts\s+:/)
173
- macros[:normalizes] = source.scan(/\bnormalizes\s+(.+)/).flat_map { |m| m[0].scan(/:(\w+)/).flatten } if source.match?(/\bnormalizes\s+:/)
172
+ macros[:encrypts] = source.scan(/\bencrypts\s+(.+?)$/).flat_map { |m| m[0].scan(/:(\w+)/).flatten } if source.match?(/\bencrypts\s+:/)
173
+ macros[:normalizes] = source.scan(/\bnormalizes\s+(.+?)$/).flat_map { |m| m[0].scan(/:(\w+)/).flatten } if source.match?(/\bnormalizes\s+:/)
174
174
  macros[:has_one_attached] = source.scan(/\bhas_one_attached\s+:(\w+)/).flatten if source.match?(/\bhas_one_attached\s+:/)
175
175
  macros[:has_many_attached] = source.scan(/\bhas_many_attached\s+:(\w+)/).flatten if source.match?(/\bhas_many_attached\s+:/)
176
176
  macros[:has_rich_text] = source.scan(/\bhas_rich_text\s+:(\w+)/).flatten if source.match?(/\bhas_rich_text\s+:/)
@@ -180,7 +180,7 @@ module RailsAiContext
180
180
  macros[:store] = source.scan(/\bstore(?:_accessor)?\s+:(\w+)/).flatten if source.match?(/\bstore(?:_accessor)?\s+:/)
181
181
 
182
182
  # Delegations
183
- delegations = source.scan(/\bdelegate\s+(.+?),\s*to:\s*:(\w+)/).map do |methods_str, target|
183
+ delegations = source.scan(/\bdelegate\s+(.+?),\s*to:\s*:(\w+)/m).map do |methods_str, target|
184
184
  { methods: methods_str.scan(/:(\w+)/).flatten, to: target }
185
185
  end
186
186
  macros[:delegations] = delegations if delegations.any?
@@ -14,11 +14,13 @@ module RailsAiContext
14
14
 
15
15
  def call
16
16
  views_dir = File.join(app.root.to_s, "app", "views")
17
- return { templates: {}, partials: {} } unless Dir.exist?(views_dir)
17
+ return { templates: {}, partials: {}, ui_patterns: {} } unless Dir.exist?(views_dir)
18
18
 
19
+ all_content = collect_all_view_content(views_dir)
19
20
  {
20
21
  templates: scan_templates(views_dir),
21
- partials: scan_partials(views_dir)
22
+ partials: scan_partials(views_dir),
23
+ ui_patterns: extract_ui_patterns(all_content)
22
24
  }
23
25
  rescue => e
24
26
  { error: e.message }
@@ -49,12 +51,113 @@ module RailsAiContext
49
51
  Dir.glob(File.join(views_dir, "**", "_*")).each do |path|
50
52
  next if File.directory?(path)
51
53
  relative = path.sub("#{views_dir}/", "")
52
- lines = File.read(path).lines.count rescue 0
53
- partials[relative] = { lines: lines }
54
+ content = File.read(path) rescue next
55
+ partials[relative] = {
56
+ lines: content.lines.count,
57
+ fields: extract_model_fields(content),
58
+ helpers: extract_helper_calls(content)
59
+ }
54
60
  end
55
61
  partials
56
62
  end
57
63
 
64
+ def collect_all_view_content(views_dir)
65
+ content = ""
66
+ Dir.glob(File.join(views_dir, "**", "*.{erb,haml,slim}")).each do |path|
67
+ next if File.directory?(path)
68
+ content += (File.read(path) rescue "")
69
+ end
70
+ content
71
+ end
72
+
73
+ def extract_ui_patterns(all_content) # rubocop:disable Metrics/MethodLength
74
+ patterns = {}
75
+ class_groups = Hash.new(0)
76
+
77
+ # Match both double and single quoted class attributes
78
+ all_content.scan(/class="([^"]+)"/).each do |m|
79
+ classes = m[0].gsub(/<%=.*?%>/, "").strip # Strip ERB interpolation
80
+ next if classes.length < 5
81
+ class_groups[classes] += 1
82
+ end
83
+ all_content.scan(/class='([^']+)'/).each do |m|
84
+ classes = m[0].gsub(/<%=.*?%>/, "").strip
85
+ next if classes.length < 5
86
+ class_groups[classes] += 1
87
+ end
88
+
89
+ # Find repeated patterns (used 2+ times) grouped by element type
90
+ # Framework-agnostic: works with Tailwind, Bootstrap, or custom CSS
91
+ buttons = []
92
+ cards = []
93
+ inputs = []
94
+ labels = []
95
+ badges = []
96
+ links = []
97
+
98
+ class_groups.each do |classes, count|
99
+ next if count < 2
100
+
101
+ if classes.match?(/btn|button|submit|bg-\w+-\d+.*text-white|hover:bg|btn-primary|btn-secondary/)
102
+ buttons << classes
103
+ elsif classes.match?(/card|panel|shadow|border.*rounded.*p-\d|bg-white.*rounded/)
104
+ cards << classes
105
+ elsif classes.match?(/input|field|form-control|border.*rounded.*px-\d|focus:ring|focus:border/)
106
+ inputs << classes
107
+ elsif classes.match?(/label|font-semibold.*mb-|font-medium.*mb-|form-label|block.*text-sm/)
108
+ labels << classes
109
+ elsif classes.match?(/badge|rounded-full|pill|tag|px-2.*py-1.*text-xs/)
110
+ badges << classes
111
+ elsif classes.match?(/link|hover:text-|hover:underline|hover:bg-.*text-.*font-/)
112
+ links << classes
113
+ end
114
+ end
115
+
116
+ patterns[:buttons] = buttons.first(3) if buttons.any?
117
+ patterns[:cards] = cards.first(3) if cards.any?
118
+ patterns[:inputs] = inputs.first(3) if inputs.any?
119
+ patterns[:labels] = labels.first(3) if labels.any?
120
+ patterns[:badges] = badges.first(3) if badges.any?
121
+ patterns[:links] = links.first(3) if links.any?
122
+
123
+ patterns
124
+ end
125
+
126
+ EXCLUDED_METHODS = %w[
127
+ each map select reject first last size count any? empty? present? blank?
128
+ new build create find where order limit nil? join class html_safe
129
+ to_s to_i to_f inspect strip chomp downcase upcase capitalize
130
+ humanize pluralize singularize truncate gsub sub scan match split
131
+ freeze dup clone length bytes chars reverse uniq compact flatten
132
+ flat_map zip sort sort_by min max sum group_by
133
+ persisted? new_record? valid? errors reload save destroy update
134
+ delete respond_to? is_a? kind_of? send try
135
+ abs round ceil floor
136
+ strftime iso8601 beginning_of_day end_of_day ago from_now
137
+ ].freeze
138
+
139
+ def extract_model_fields(content)
140
+ fields = []
141
+ content.scan(/(?:@?\w+)\.(\w+)/).each do |m|
142
+ field = m[0]
143
+ next if field.length < 3 || field.length > 40
144
+ next if field.match?(/\A[0-9a-f]+\z/)
145
+ next if field.match?(/\A[A-Z]/) # skip constant/class access
146
+ next if EXCLUDED_METHODS.include?(field)
147
+ next if field.start_with?("to_", "html_")
148
+ next if field.end_with?("?", "!")
149
+ fields << field
150
+ end
151
+ fields.uniq.first(15)
152
+ end
153
+
154
+ def extract_helper_calls(content)
155
+ helpers = []
156
+ # Custom helper methods (render_*, format_*, *_path, *_url)
157
+ content.scan(/\b(render_\w+|format_\w+)\b/).each { |m| helpers << m[0] }
158
+ helpers.uniq
159
+ end
160
+
58
161
  def extract_partial_refs(content)
59
162
  refs = []
60
163
  # render "partial_name" or render partial: "name"
@@ -24,6 +24,7 @@ module RailsAiContext
24
24
  "rails-context.md" => render_context_overview,
25
25
  "rails-schema.md" => render_schema_reference,
26
26
  "rails-models.md" => render_models_reference,
27
+ "rails-ui-patterns.md" => render_ui_patterns_reference,
27
28
  "rails-mcp-tools.md" => render_mcp_tools_reference
28
29
  }
29
30
 
@@ -96,11 +97,34 @@ module RailsAiContext
96
97
  ""
97
98
  ]
98
99
 
99
- tables.keys.sort.each do |name|
100
+ skip_cols = %w[id created_at updated_at]
101
+ # Always show these even if they end in _id/_type (important for AI)
102
+ keep_cols = %w[type deleted_at discarded_at]
103
+
104
+ tables.keys.sort.first(30).each do |name|
100
105
  data = tables[name]
101
- col_count = data[:columns]&.size || 0
102
- pk = data[:primary_key] || "id"
103
- lines << "- #{name} (#{col_count} cols, pk: #{pk})"
106
+ columns = data[:columns] || []
107
+ col_count = columns.size
108
+ pk = data[:primary_key]
109
+ pk_display = pk.is_a?(Array) ? pk.join(", ") : (pk || "id").to_s
110
+
111
+ key_cols = columns.map { |c| c[:name] }.select do |c|
112
+ next true if keep_cols.include?(c)
113
+ next true if c.end_with?("_type") # polymorphic associations
114
+ next false if skip_cols.include?(c)
115
+ next false if c.end_with?("_id")
116
+ true
117
+ end
118
+
119
+ col_sample = key_cols.first(12)
120
+ shown = col_sample.join(", ")
121
+ shown += ", ..." if key_cols.size > 12
122
+ col_names = col_sample.any? ? " — #{shown}" : ""
123
+ lines << "- #{name} (#{col_count} cols, pk: #{pk_display})#{col_names}"
124
+ end
125
+
126
+ if tables.size > 30
127
+ lines << "- ...#{tables.size - 30} more tables (use `rails_get_schema` MCP tool)"
104
128
  end
105
129
 
106
130
  lines.join("\n")
@@ -133,6 +157,29 @@ module RailsAiContext
133
157
  lines.join("\n")
134
158
  end
135
159
 
160
+ def render_ui_patterns_reference
161
+ vt = context[:view_templates]
162
+ return nil unless vt.is_a?(Hash) && !vt[:error]
163
+ patterns = vt[:ui_patterns] || {}
164
+ return nil if patterns.empty?
165
+
166
+ lines = [
167
+ "# UI Patterns",
168
+ "",
169
+ "Common CSS class patterns found in this app's views.",
170
+ "Use these when creating new views to match the existing design.",
171
+ ""
172
+ ]
173
+
174
+ patterns.each do |type, classes_list|
175
+ classes_list.each do |classes|
176
+ lines << "- #{type.to_s.chomp('s').capitalize}: `#{classes}`"
177
+ end
178
+ end
179
+
180
+ lines.join("\n")
181
+ end
182
+
136
183
  def render_mcp_tools_reference # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
137
184
  lines = [
138
185
  "# Rails MCP Tools — ALWAYS Use These First",
@@ -29,6 +29,7 @@ module RailsAiContext
29
29
  lines.concat(render_key_models)
30
30
  lines.concat(render_notable_gems)
31
31
  lines.concat(render_architecture)
32
+ lines.concat(render_ui_patterns)
32
33
  lines.concat(render_mcp_guide)
33
34
  lines.concat(render_conventions)
34
35
  lines.concat(render_commands)
@@ -158,6 +159,22 @@ module RailsAiContext
158
159
  lines
159
160
  end
160
161
 
162
+ def render_ui_patterns
163
+ vt = context[:view_templates]
164
+ return [] unless vt.is_a?(Hash) && !vt[:error]
165
+ patterns = vt[:ui_patterns] || {}
166
+ return [] if patterns.empty?
167
+
168
+ lines = [ "## UI Patterns" ]
169
+ patterns.each do |type, classes_list|
170
+ classes_list.each do |classes|
171
+ lines << "- #{type.to_s.chomp('s').capitalize}: `#{classes}`"
172
+ end
173
+ end
174
+ lines << ""
175
+ lines
176
+ end
177
+
161
178
  def render_mcp_guide # rubocop:disable Metrics/MethodLength
162
179
  [
163
180
  "## MCP Tools (11) — ALWAYS Use These First",
@@ -22,6 +22,7 @@ module RailsAiContext
22
22
  "rails-context.instructions.md" => render_context_instructions,
23
23
  "rails-models.instructions.md" => render_models_instructions,
24
24
  "rails-controllers.instructions.md" => render_controllers_instructions,
25
+ "rails-ui-patterns.instructions.md" => render_ui_patterns_instructions,
25
26
  "rails-mcp-tools.instructions.md" => render_mcp_tools_instructions
26
27
  }
27
28
 
@@ -134,6 +135,30 @@ module RailsAiContext
134
135
  lines.join("\n")
135
136
  end
136
137
 
138
+ def render_ui_patterns_instructions
139
+ vt = context[:view_templates]
140
+ return nil unless vt.is_a?(Hash) && !vt[:error]
141
+ patterns = vt[:ui_patterns] || {}
142
+ return nil if patterns.empty?
143
+
144
+ lines = [
145
+ "---",
146
+ "applyTo: \"app/views/**/*.erb\"",
147
+ "---",
148
+ "",
149
+ "# UI Patterns",
150
+ "",
151
+ "Use these CSS class patterns to match the existing design.",
152
+ ""
153
+ ]
154
+
155
+ patterns.each do |type, classes_list|
156
+ classes_list.each { |c| lines << "- #{type.to_s.chomp('s').capitalize}: `#{c}`" }
157
+ end
158
+
159
+ lines.join("\n")
160
+ end
161
+
137
162
  def render_mcp_tools_instructions # rubocop:disable Metrics/MethodLength
138
163
  lines = [
139
164
  "---",
@@ -80,6 +80,19 @@ module RailsAiContext
80
80
  end
81
81
  end
82
82
 
83
+ # UI Patterns
84
+ vt = context[:view_templates]
85
+ if vt.is_a?(Hash) && !vt[:error]
86
+ patterns = vt[:ui_patterns] || {}
87
+ if patterns.any?
88
+ lines << "## UI Patterns"
89
+ patterns.each do |type, classes_list|
90
+ classes_list.each { |c| lines << "- #{type.to_s.chomp('s').capitalize}: `#{c}`" }
91
+ end
92
+ lines << ""
93
+ end
94
+ end
95
+
83
96
  # MCP tools
84
97
  lines << "## MCP Tool Reference"
85
98
  lines << ""
@@ -25,6 +25,7 @@ module RailsAiContext
25
25
  "rails-project.mdc" => render_project_rule,
26
26
  "rails-models.mdc" => render_models_rule,
27
27
  "rails-controllers.mdc" => render_controllers_rule,
28
+ "rails-ui-patterns.mdc" => render_ui_patterns_rule,
28
29
  "rails-mcp-tools.mdc" => render_mcp_tools_rule
29
30
  }
30
31
 
@@ -154,6 +155,33 @@ module RailsAiContext
154
155
  lines.join("\n")
155
156
  end
156
157
 
158
+ def render_ui_patterns_rule
159
+ vt = context[:view_templates]
160
+ return nil unless vt.is_a?(Hash) && !vt[:error]
161
+ patterns = vt[:ui_patterns] || {}
162
+ return nil if patterns.empty?
163
+
164
+ lines = [
165
+ "---",
166
+ "description: \"UI/CSS patterns used in this Rails app\"",
167
+ "globs:",
168
+ " - \"app/views/**/*.erb\"",
169
+ "alwaysApply: false",
170
+ "---",
171
+ "",
172
+ "# UI Patterns",
173
+ "",
174
+ "Use these CSS class patterns to match the existing design.",
175
+ ""
176
+ ]
177
+
178
+ patterns.each do |type, classes_list|
179
+ classes_list.each { |c| lines << "- #{type.to_s.chomp('s').capitalize}: `#{c}`" }
180
+ end
181
+
182
+ lines.join("\n")
183
+ end
184
+
157
185
  # Always-on MCP tool reference — strongest enforcement point for Cursor
158
186
  def render_mcp_tools_rule # rubocop:disable Metrics/MethodLength
159
187
  lines = [
@@ -29,6 +29,7 @@ module RailsAiContext
29
29
  lines.concat(render_key_models)
30
30
  lines.concat(render_notable_gems)
31
31
  lines.concat(render_architecture)
32
+ lines.concat(render_ui_patterns)
32
33
  lines.concat(render_mcp_guide)
33
34
  lines.concat(render_conventions)
34
35
  lines.concat(render_commands)
@@ -158,6 +159,22 @@ module RailsAiContext
158
159
  lines
159
160
  end
160
161
 
162
+ def render_ui_patterns
163
+ vt = context[:view_templates]
164
+ return [] unless vt.is_a?(Hash) && !vt[:error]
165
+ patterns = vt[:ui_patterns] || {}
166
+ return [] if patterns.empty?
167
+
168
+ lines = [ "## UI Patterns" ]
169
+ patterns.each do |type, classes_list|
170
+ classes_list.each do |classes|
171
+ lines << "- #{type.to_s.chomp('s').capitalize}: `#{classes}`"
172
+ end
173
+ end
174
+ lines << ""
175
+ lines
176
+ end
177
+
161
178
  def render_mcp_guide # rubocop:disable Metrics/MethodLength
162
179
  [
163
180
  "## MCP Tools (11) — ALWAYS Use These First",
@@ -73,6 +73,19 @@ module RailsAiContext
73
73
  end
74
74
  end
75
75
 
76
+ # UI Patterns
77
+ vt = context[:view_templates]
78
+ if vt.is_a?(Hash) && !vt[:error]
79
+ patterns = vt[:ui_patterns] || {}
80
+ if patterns.any?
81
+ lines << "## UI Patterns"
82
+ patterns.each do |type, classes_list|
83
+ classes_list.each { |c| lines << "- #{type.to_s.chomp('s').capitalize}: `#{c}`" }
84
+ end
85
+ lines << ""
86
+ end
87
+ end
88
+
76
89
  # MCP tools
77
90
  lines << "## MCP Tool Reference"
78
91
  lines << ""
@@ -22,6 +22,7 @@ module RailsAiContext
22
22
 
23
23
  files = {
24
24
  "rails-context.md" => render_context_rule,
25
+ "rails-ui-patterns.md" => render_ui_patterns_rule,
25
26
  "rails-mcp-tools.md" => render_mcp_tools_rule
26
27
  }
27
28
 
@@ -49,6 +50,19 @@ module RailsAiContext
49
50
  WindsurfSerializer.new(context).call
50
51
  end
51
52
 
53
+ def render_ui_patterns_rule
54
+ vt = context[:view_templates]
55
+ return nil unless vt.is_a?(Hash) && !vt[:error]
56
+ patterns = vt[:ui_patterns] || {}
57
+ return nil if patterns.empty?
58
+
59
+ lines = [ "# UI Patterns", "", "Match these CSS classes when creating new views.", "" ]
60
+ patterns.each do |type, classes_list|
61
+ classes_list.first(2).each { |c| lines << "- #{type}: `#{c}`" }
62
+ end
63
+ lines.join("\n")
64
+ end
65
+
52
66
  def render_mcp_tools_rule # rubocop:disable Metrics/MethodLength
53
67
  lines = [
54
68
  "# Rails MCP Tools (11) — Use These First",
@@ -71,6 +71,17 @@ module RailsAiContext
71
71
  end
72
72
  end
73
73
 
74
+ # UI Patterns (compact — character budget is tight)
75
+ vt = context[:view_templates]
76
+ if vt.is_a?(Hash) && !vt[:error]
77
+ patterns = vt[:ui_patterns] || {}
78
+ if patterns.any?
79
+ lines << "# UI Patterns"
80
+ patterns.each { |type, list| list.first(1).each { |c| lines << "- #{type}: `#{c}`" } }
81
+ lines << ""
82
+ end
83
+ end
84
+
74
85
  # MCP tools — compact but complete (character budget is tight)
75
86
  lines << "# MCP Tools (detail:\"summary\"|\"standard\"|\"full\")"
76
87
  lines << "- rails_get_schema(table:\"name\"|detail:\"summary\"|limit:N|offset:N)"
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- ASSISTANT_TABLE = <<~TABLE
3
+ ASSISTANT_TABLE = <<~TABLE unless defined?(ASSISTANT_TABLE)
4
4
  AI Assistant Context File Command
5
5
  -- -- --
6
6
  Claude Code CLAUDE.md + .claude/rules/ rails ai:context:claude
@@ -14,7 +14,7 @@ TABLE
14
14
  def print_result(result)
15
15
  result[:written].each { |f| puts " ✅ #{f}" }
16
16
  result[:skipped].each { |f| puts " ⏭️ #{f} (unchanged)" }
17
- end
17
+ end unless defined?(print_result)
18
18
 
19
19
  def apply_context_mode_override
20
20
  if ENV["CONTEXT_MODE"]
@@ -22,7 +22,7 @@ def apply_context_mode_override
22
22
  RailsAiContext.configuration.context_mode = mode
23
23
  puts "📐 Context mode: #{mode}"
24
24
  end
25
- end
25
+ end unless defined?(apply_context_mode_override)
26
26
 
27
27
  namespace :ai do
28
28
  desc "Generate AI context files (CLAUDE.md, .cursor/rules/, .windsurfrules, .github/copilot-instructions.md)"
@@ -63,15 +63,32 @@ module RailsAiContext
63
63
  text_response(lines.join("\n"))
64
64
 
65
65
  when "standard"
66
- lines = [ "# Views (#{templates.size} templates)", "" ]
66
+ all_partials = data[:partials] || {}
67
+ lines = [ "# Views (#{templates.size} templates, #{all_partials.size} partials)", "" ]
67
68
  templates.keys.map { |k| k.split("/").first }.uniq.sort.each do |ctrl|
68
69
  ctrl_templates = templates.select { |k, _| k.start_with?("#{ctrl}/") }
69
70
  lines << "## #{ctrl}/"
70
71
  ctrl_templates.sort.each do |name, meta|
71
- parts = meta[:partials]&.any? ? " partials: #{meta[:partials].join(', ')}" : ""
72
+ parts = meta[:partials]&.any? ? " renders: #{meta[:partials].join(', ')}" : ""
72
73
  stim = meta[:stimulus]&.any? ? " stimulus: #{meta[:stimulus].join(', ')}" : ""
73
74
  lines << "- #{File.basename(name)} (#{meta[:lines]} lines)#{parts}#{stim}"
74
75
  end
76
+ # Show partials for this controller with field/helper info
77
+ ctrl_partials = all_partials.select { |k, _| k.start_with?("#{ctrl}/") }
78
+ ctrl_partials.sort.each do |name, meta|
79
+ fields = meta[:fields]&.any? ? " fields: #{meta[:fields].join(', ')}" : ""
80
+ helpers = meta[:helpers]&.any? ? " helpers: #{meta[:helpers].join(', ')}" : ""
81
+ lines << "- #{File.basename(name)} (#{meta[:lines]} lines)#{fields}#{helpers}"
82
+ end
83
+ lines << ""
84
+ end
85
+ # Show shared partials
86
+ shared = all_partials.select { |k, _| k.start_with?("shared/") }
87
+ if shared.any?
88
+ lines << "## shared/"
89
+ shared.sort.each do |name, meta|
90
+ lines << "- #{File.basename(name)} (#{meta[:lines]} lines)"
91
+ end
75
92
  lines << ""
76
93
  end
77
94
  text_response(lines.join("\n"))
@@ -92,6 +109,8 @@ module RailsAiContext
92
109
  end
93
110
  end
94
111
 
112
+ MAX_FILE_SIZE = 2_000_000 # 2MB safety limit
113
+
95
114
  private_class_method def self.read_view_file(path)
96
115
  views_dir = Rails.root.join("app", "views")
97
116
  full_path = views_dir.join(path)
@@ -103,6 +122,9 @@ module RailsAiContext
103
122
  unless File.exist?(full_path)
104
123
  return text_response("View not found: #{path}")
105
124
  end
125
+ if File.size(full_path) > MAX_FILE_SIZE
126
+ return text_response("File too large: #{path} (#{File.size(full_path)} bytes)")
127
+ end
106
128
 
107
129
  content = File.read(full_path)
108
130
  text_response("# #{path}\n\n```erb\n#{content}\n```")
@@ -106,7 +106,11 @@ module RailsAiContext
106
106
 
107
107
  private_class_method def self.search_with_ruby(pattern, search_path, file_type, max_results, root)
108
108
  results = []
109
- regex = Regexp.new(pattern, Regexp::IGNORECASE)
109
+ begin
110
+ regex = Regexp.new(pattern, Regexp::IGNORECASE, timeout: 2)
111
+ rescue RegexpError => e
112
+ return [ { file: "error", line_number: 0, content: "Invalid regex: #{e.message}" } ]
113
+ end
110
114
  glob = file_type ? "**/*.#{file_type}" : "**/*.{rb,js,erb,yml,yaml,json}"
111
115
  excluded = RailsAiContext.configuration.excluded_paths
112
116
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "0.10.1"
4
+ VERSION = "0.11.0"
5
5
  end
data/server.json CHANGED
@@ -7,11 +7,11 @@
7
7
  "url": "https://github.com/crisnahine/rails-ai-context",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.10.1",
10
+ "version": "0.11.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "mcpb",
14
- "identifier": "https://github.com/crisnahine/rails-ai-context/releases/download/v0.10.1/rails-ai-context-mcp.mcpb",
14
+ "identifier": "https://github.com/crisnahine/rails-ai-context/releases/download/v0.11.0/rails-ai-context-mcp.mcpb",
15
15
  "fileSha256": "dd711a0ad6c4de943ae4da94eaf59a6dc9494b9d57f726e24649ed4e2f156990",
16
16
  "transport": {
17
17
  "type": "stdio"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-ai-context
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.1
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine