rails-ai-context 2.0.1 → 2.0.3
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 +25 -0
- data/README.md +139 -326
- data/docs/GUIDE.md +23 -14
- data/lib/generators/rails_ai_context/install/install_generator.rb +79 -48
- data/lib/rails_ai_context/configuration.rb +5 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +79 -8
- data/lib/rails_ai_context/tools/get_routes.rb +13 -1
- data/lib/rails_ai_context/tools/get_schema.rb +13 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +88 -0
- data/lib/rails_ai_context/tools/get_view.rb +7 -5
- data/lib/rails_ai_context/tools/search_code.rb +307 -40
- data/lib/rails_ai_context/tools/validate.rb +48 -0
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +2 -2
- metadata +1 -1
data/docs/GUIDE.md
CHANGED
|
@@ -561,31 +561,39 @@ Ripgrep-powered regex search across the codebase.
|
|
|
561
561
|
|
|
562
562
|
| Param | Type | Description |
|
|
563
563
|
|-------|------|-------------|
|
|
564
|
-
| `pattern` | string | **Required.** Regex pattern to search for. |
|
|
564
|
+
| `pattern` | string | **Required.** Regex pattern or method name to search for. |
|
|
565
565
|
| `path` | string | Subdirectory to search in (e.g. `app/models`, `config`). Default: entire app. |
|
|
566
|
-
| `file_type` | string | Filter by file
|
|
567
|
-
| `match_type` | string |
|
|
566
|
+
| `file_type` | string | Filter by file extension (e.g. `rb`, `erb`, `js`). Alphanumeric only. |
|
|
567
|
+
| `match_type` | string | `any` (default), `definition` (def lines), `class` (class/module lines), `call` (call sites only), `trace` (**full picture** — definition with class context + source code + internal calls + sibling methods + callers with route chain + test coverage separated). |
|
|
568
568
|
| `exact_match` | boolean | Match whole words only (wraps pattern in `\b` boundaries). Default: false. |
|
|
569
|
-
| `
|
|
569
|
+
| `exclude_tests` | boolean | Exclude test/spec/features directories. Default: false. |
|
|
570
|
+
| `group_by_file` | boolean | Group results by file with match counts. Default: false. |
|
|
571
|
+
| `offset` | integer | Skip this many results for pagination. Default: 0. |
|
|
570
572
|
| `context_lines` | integer | Lines of context before and after each match (like grep -C). Default: 2, max: 5. |
|
|
571
573
|
|
|
574
|
+
Smart result limiting: <10 results shows all, 10-100 shows half, >100 caps at 100. Use `offset` for pagination.
|
|
575
|
+
|
|
572
576
|
**Examples:**
|
|
573
577
|
|
|
574
578
|
```
|
|
575
|
-
rails_search_code(pattern: "
|
|
576
|
-
→
|
|
579
|
+
rails_search_code(pattern: "can_cook?", match_type: "trace")
|
|
580
|
+
→ FULL PICTURE: definition with class context + source code + internal calls
|
|
581
|
+
+ sibling methods + app callers with route chain + test coverage (separated)
|
|
582
|
+
|
|
583
|
+
rails_search_code(pattern: "create", match_type: "definition")
|
|
584
|
+
→ Only `def create` / `def self.create` lines
|
|
577
585
|
|
|
578
|
-
rails_search_code(pattern: "
|
|
579
|
-
→
|
|
586
|
+
rails_search_code(pattern: "can_cook", match_type: "call")
|
|
587
|
+
→ Only call sites (excludes the definition)
|
|
580
588
|
|
|
581
|
-
rails_search_code(pattern: "
|
|
582
|
-
→
|
|
589
|
+
rails_search_code(pattern: "Controller", match_type: "class")
|
|
590
|
+
→ All class/module definitions matching *Controller
|
|
583
591
|
|
|
584
|
-
rails_search_code(pattern: "
|
|
585
|
-
→
|
|
592
|
+
rails_search_code(pattern: "has_many", group_by_file: true)
|
|
593
|
+
→ Results grouped by file with match counts
|
|
586
594
|
|
|
587
|
-
rails_search_code(pattern: "
|
|
588
|
-
→
|
|
595
|
+
rails_search_code(pattern: "cook", exclude_tests: true)
|
|
596
|
+
→ Skip test/spec directories
|
|
589
597
|
|
|
590
598
|
rails_search_code(pattern: "activate", match_type: "definition")
|
|
591
599
|
→ Only `def activate` / `def self.activate` lines (skips method calls)
|
|
@@ -1175,6 +1183,7 @@ end
|
|
|
1175
1183
|
| `cache_ttl` | Integer | `60` | Cache TTL in seconds for introspection results |
|
|
1176
1184
|
| `custom_tools` | Array | `[]` | Additional MCP tool classes to register alongside built-in tools |
|
|
1177
1185
|
| `skip_tools` | Array | `[]` | Built-in tool names to exclude (e.g. `%w[rails_security_scan]`) |
|
|
1186
|
+
| `ai_tools` | Array | `nil` (all) | AI tools to generate context for: `%i[claude cursor copilot windsurf opencode]`. Selected during install. |
|
|
1178
1187
|
| `excluded_models` | Array | internal Rails models | Models to skip |
|
|
1179
1188
|
| `excluded_paths` | Array | `node_modules tmp log vendor .git` | Paths excluded from code search |
|
|
1180
1189
|
| `sensitive_patterns` | Array | `.env`, `.key`, `.pem`, credentials | File patterns blocked from search and read tools |
|
|
@@ -9,6 +9,43 @@ module RailsAiContext
|
|
|
9
9
|
|
|
10
10
|
desc "Install rails-ai-context: creates initializer, MCP config, and generates initial context files."
|
|
11
11
|
|
|
12
|
+
AI_TOOLS = {
|
|
13
|
+
"1" => { key: :claude, name: "Claude Code", files: "CLAUDE.md + .claude/rules/", format: :claude },
|
|
14
|
+
"2" => { key: :cursor, name: "Cursor", files: ".cursor/rules/", format: :cursor },
|
|
15
|
+
"3" => { key: :copilot, name: "GitHub Copilot", files: ".github/copilot-instructions.md + .github/instructions/", format: :copilot },
|
|
16
|
+
"4" => { key: :windsurf, name: "Windsurf", files: ".windsurfrules + .windsurf/rules/", format: :windsurf },
|
|
17
|
+
"5" => { key: :opencode, name: "OpenCode", files: "AGENTS.md", format: :opencode }
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def select_ai_tools
|
|
21
|
+
say ""
|
|
22
|
+
say "Which AI tools do you use? (select all that apply)", :yellow
|
|
23
|
+
say ""
|
|
24
|
+
AI_TOOLS.each do |num, info|
|
|
25
|
+
say " #{num}. #{info[:name].ljust(16)} → #{info[:files]}"
|
|
26
|
+
end
|
|
27
|
+
say " a. All of the above"
|
|
28
|
+
say ""
|
|
29
|
+
|
|
30
|
+
input = ask("Enter numbers separated by commas (e.g. 1,2) or 'a' for all:").strip.downcase
|
|
31
|
+
|
|
32
|
+
@selected_formats = if input == "a" || input == "all"
|
|
33
|
+
AI_TOOLS.values.map { |t| t[:format] }
|
|
34
|
+
else
|
|
35
|
+
nums = input.split(/[\s,]+/)
|
|
36
|
+
nums.filter_map { |n| AI_TOOLS[n]&.dig(:format) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if @selected_formats.empty?
|
|
40
|
+
say "No tools selected — defaulting to all.", :yellow
|
|
41
|
+
@selected_formats = AI_TOOLS.values.map { |t| t[:format] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
selected_names = AI_TOOLS.values.select { |t| @selected_formats.include?(t[:format]) }.map { |t| t[:name] }
|
|
45
|
+
say ""
|
|
46
|
+
say "Selected: #{selected_names.join(', ')}", :green
|
|
47
|
+
end
|
|
48
|
+
|
|
12
49
|
def create_mcp_config
|
|
13
50
|
mcp_path = Rails.root.join(".mcp.json")
|
|
14
51
|
server_entry = {
|
|
@@ -36,46 +73,33 @@ module RailsAiContext
|
|
|
36
73
|
end
|
|
37
74
|
|
|
38
75
|
def create_initializer
|
|
76
|
+
tools_line = if @selected_formats.size == AI_TOOLS.size
|
|
77
|
+
" # config.ai_tools = %i[claude cursor copilot windsurf opencode] # default: all"
|
|
78
|
+
else
|
|
79
|
+
" config.ai_tools = %i[#{@selected_formats.join(' ')}]"
|
|
80
|
+
end
|
|
81
|
+
|
|
39
82
|
create_file "config/initializers/rails_ai_context.rb", <<~RUBY
|
|
40
83
|
# frozen_string_literal: true
|
|
41
84
|
|
|
42
85
|
RailsAiContext.configure do |config|
|
|
86
|
+
# AI tools to generate context files for (selected during install)
|
|
87
|
+
# Run `rails generate rails_ai_context:install` to change selection
|
|
88
|
+
#{tools_line}
|
|
89
|
+
|
|
43
90
|
# Introspector preset:
|
|
44
|
-
# :full — all 28 introspectors (default
|
|
45
|
-
# :standard — 13 core introspectors
|
|
91
|
+
# :full — all 28 introspectors (default)
|
|
92
|
+
# :standard — 13 core introspectors
|
|
46
93
|
# config.preset = :full
|
|
47
94
|
|
|
48
|
-
# Or cherry-pick individual introspectors:
|
|
49
|
-
# config.introspectors += %i[views turbo auth api]
|
|
50
|
-
|
|
51
95
|
# Models to exclude from introspection
|
|
52
96
|
# config.excluded_models += %w[AdminUser InternalThing]
|
|
53
97
|
|
|
54
|
-
#
|
|
55
|
-
# config.excluded_paths += %w[vendor/bundle]
|
|
56
|
-
|
|
57
|
-
# Context mode for generated files (CLAUDE.md, .cursor/rules/, etc.)
|
|
58
|
-
# :compact — smart, ≤150 lines, references MCP tools for details (default)
|
|
59
|
-
# :full — dumps everything into context files (good for small apps <30 models)
|
|
98
|
+
# Context mode: :compact (default, ≤150 lines) or :full (dumps everything)
|
|
60
99
|
# config.context_mode = :compact
|
|
61
100
|
|
|
62
|
-
# Max lines for CLAUDE.md in compact mode
|
|
63
|
-
# config.claude_max_lines = 150
|
|
64
|
-
|
|
65
|
-
# Max response size for MCP tool results (chars). Safety net for large apps.
|
|
66
|
-
# config.max_tool_response_chars = 120_000
|
|
67
|
-
|
|
68
101
|
# Live reload: auto-invalidate MCP tool caches on file changes
|
|
69
|
-
# :auto (default) — enable if `listen` gem is available
|
|
70
|
-
# true — enable, raise if `listen` is missing
|
|
71
|
-
# false — disable entirely
|
|
72
102
|
# config.live_reload = :auto
|
|
73
|
-
# config.live_reload_debounce = 1.5 # seconds
|
|
74
|
-
|
|
75
|
-
# Auto-mount HTTP MCP endpoint at /mcp
|
|
76
|
-
# config.auto_mount = false
|
|
77
|
-
# config.http_path = "/mcp"
|
|
78
|
-
# config.http_port = 6029
|
|
79
103
|
end
|
|
80
104
|
RUBY
|
|
81
105
|
|
|
@@ -104,13 +128,21 @@ module RailsAiContext
|
|
|
104
128
|
say ""
|
|
105
129
|
say "Generating AI context files...", :yellow
|
|
106
130
|
|
|
107
|
-
|
|
108
|
-
require "rails_ai_context"
|
|
109
|
-
context = RailsAiContext.introspect
|
|
110
|
-
files = RailsAiContext.generate_context(format: :all)
|
|
111
|
-
files.each { |f| say " Created #{f}", :green }
|
|
112
|
-
else
|
|
131
|
+
unless Rails.application
|
|
113
132
|
say " Skipped (Rails app not fully loaded). Run `rails ai:context` after install.", :yellow
|
|
133
|
+
return
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
require "rails_ai_context"
|
|
137
|
+
|
|
138
|
+
@selected_formats.each do |fmt|
|
|
139
|
+
begin
|
|
140
|
+
result = RailsAiContext.generate_context(format: fmt)
|
|
141
|
+
(result[:written] || []).each { |f| say " ✅ #{f}", :green }
|
|
142
|
+
(result[:skipped] || []).each { |f| say " ⏭️ #{f} (unchanged)", :yellow }
|
|
143
|
+
rescue => e
|
|
144
|
+
say " ❌ #{fmt}: #{e.message}", :red
|
|
145
|
+
end
|
|
114
146
|
end
|
|
115
147
|
end
|
|
116
148
|
|
|
@@ -120,27 +152,26 @@ module RailsAiContext
|
|
|
120
152
|
say " rails-ai-context installed!", :cyan
|
|
121
153
|
say "=" * 50, :cyan
|
|
122
154
|
say ""
|
|
123
|
-
say "
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
say " rails ai:inspect # Print introspection summary"
|
|
155
|
+
say "Your setup:", :yellow
|
|
156
|
+
AI_TOOLS.each_value do |info|
|
|
157
|
+
next unless @selected_formats.include?(info[:format])
|
|
158
|
+
say " ✅ #{info[:name].ljust(16)} → #{info[:files]}"
|
|
159
|
+
end
|
|
129
160
|
say ""
|
|
130
|
-
say "
|
|
131
|
-
say "
|
|
132
|
-
say "
|
|
133
|
-
say "
|
|
134
|
-
say "
|
|
135
|
-
say " GitHub Copilot → .github/copilot-instructions.md + .github/instructions/*.instructions.md"
|
|
161
|
+
say "Commands:", :yellow
|
|
162
|
+
say " rails ai:context # Regenerate context files"
|
|
163
|
+
say " rails ai:serve # Start MCP server (25 live tools)"
|
|
164
|
+
say " rails ai:doctor # Check AI readiness"
|
|
165
|
+
say " rails ai:inspect # Print introspection summary"
|
|
136
166
|
say ""
|
|
137
167
|
say "MCP auto-discovery:", :yellow
|
|
138
168
|
say " .mcp.json is auto-detected by Claude Code and Cursor."
|
|
139
|
-
say " No manual
|
|
169
|
+
say " No manual config needed — just open your project."
|
|
140
170
|
say ""
|
|
141
|
-
say "
|
|
142
|
-
say " rails ai:context
|
|
143
|
-
say " rails ai:context:
|
|
171
|
+
say "To add more AI tools later:", :yellow
|
|
172
|
+
say " rails ai:context:cursor # Generate for Cursor"
|
|
173
|
+
say " rails ai:context:copilot # Generate for Copilot"
|
|
174
|
+
say " rails generate rails_ai_context:install # Re-run to pick tools"
|
|
144
175
|
say ""
|
|
145
176
|
say "Commit context files and .mcp.json so your team benefits!", :green
|
|
146
177
|
end
|
|
@@ -75,6 +75,10 @@ module RailsAiContext
|
|
|
75
75
|
# Built-in tool names to skip (e.g. %w[rails_security_scan rails_get_design_system])
|
|
76
76
|
attr_accessor :skip_tools
|
|
77
77
|
|
|
78
|
+
# Which AI tools to generate context for (selected during install)
|
|
79
|
+
# nil = all formats, or %i[claude cursor copilot windsurf opencode]
|
|
80
|
+
attr_accessor :ai_tools
|
|
81
|
+
|
|
78
82
|
# Filtering — customize what's hidden from AI output
|
|
79
83
|
attr_accessor :excluded_controllers # Controller classes hidden from listings (e.g. DeviseController)
|
|
80
84
|
attr_accessor :excluded_route_prefixes # Route controller prefixes hidden with app_only (e.g. action_mailbox/)
|
|
@@ -148,6 +152,7 @@ module RailsAiContext
|
|
|
148
152
|
]
|
|
149
153
|
@custom_tools = []
|
|
150
154
|
@skip_tools = []
|
|
155
|
+
@ai_tools = nil
|
|
151
156
|
@search_extensions = %w[rb js erb yml yaml json ts tsx vue svelte haml slim]
|
|
152
157
|
@concern_paths = %w[app/models/concerns app/controllers/concerns]
|
|
153
158
|
end
|
|
@@ -24,24 +24,95 @@ def apply_context_mode_override
|
|
|
24
24
|
end
|
|
25
25
|
end unless defined?(apply_context_mode_override)
|
|
26
26
|
|
|
27
|
+
AI_TOOL_OPTIONS = {
|
|
28
|
+
"1" => { key: :claude, name: "Claude Code" },
|
|
29
|
+
"2" => { key: :cursor, name: "Cursor" },
|
|
30
|
+
"3" => { key: :copilot, name: "GitHub Copilot" },
|
|
31
|
+
"4" => { key: :windsurf, name: "Windsurf" },
|
|
32
|
+
"5" => { key: :opencode, name: "OpenCode" }
|
|
33
|
+
}.freeze unless defined?(AI_TOOL_OPTIONS)
|
|
34
|
+
|
|
35
|
+
def prompt_ai_tools
|
|
36
|
+
puts ""
|
|
37
|
+
puts "Which AI tools do you use? (select all that apply)"
|
|
38
|
+
puts ""
|
|
39
|
+
AI_TOOL_OPTIONS.each { |num, info| puts " #{num}. #{info[:name]}" }
|
|
40
|
+
puts " a. All of the above"
|
|
41
|
+
puts ""
|
|
42
|
+
print "Enter numbers separated by commas (e.g. 1,2) or 'a' for all: "
|
|
43
|
+
input = $stdin.gets&.strip&.downcase || "a"
|
|
44
|
+
|
|
45
|
+
selected = if input == "a" || input == "all" || input.empty?
|
|
46
|
+
AI_TOOL_OPTIONS.values.map { |t| t[:key] }
|
|
47
|
+
else
|
|
48
|
+
input.split(/[\s,]+/).filter_map { |n| AI_TOOL_OPTIONS[n]&.dig(:key) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if selected.empty?
|
|
52
|
+
puts "No tools selected — using all."
|
|
53
|
+
selected = AI_TOOL_OPTIONS.values.map { |t| t[:key] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
names = AI_TOOL_OPTIONS.values.select { |t| selected.include?(t[:key]) }.map { |t| t[:name] }
|
|
57
|
+
puts "Selected: #{names.join(', ')}"
|
|
58
|
+
selected
|
|
59
|
+
end unless defined?(prompt_ai_tools)
|
|
60
|
+
|
|
61
|
+
def save_ai_tools_to_initializer(tools)
|
|
62
|
+
init_path = Rails.root.join("config/initializers/rails_ai_context.rb")
|
|
63
|
+
return unless File.exist?(init_path)
|
|
64
|
+
|
|
65
|
+
content = File.read(init_path)
|
|
66
|
+
tools_line = " config.ai_tools = %i[#{tools.join(' ')}]"
|
|
67
|
+
|
|
68
|
+
if content.include?("config.ai_tools")
|
|
69
|
+
# Replace existing ai_tools line
|
|
70
|
+
content.sub!(/^.*config\.ai_tools.*$/, tools_line)
|
|
71
|
+
elsif content.include?("RailsAiContext.configure")
|
|
72
|
+
# Insert after configure block opening
|
|
73
|
+
content.sub!(/RailsAiContext\.configure do \|config\|\n/, "RailsAiContext.configure do |config|\n#{tools_line}\n")
|
|
74
|
+
else
|
|
75
|
+
return
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
File.write(init_path, content)
|
|
79
|
+
puts "💾 Saved to config/initializers/rails_ai_context.rb"
|
|
80
|
+
rescue
|
|
81
|
+
nil
|
|
82
|
+
end unless defined?(save_ai_tools_to_initializer)
|
|
83
|
+
|
|
27
84
|
namespace :ai do
|
|
28
|
-
desc "Generate AI context files (
|
|
85
|
+
desc "Generate AI context files for configured AI tools (prompts on first run)"
|
|
29
86
|
task context: :environment do
|
|
30
87
|
require "rails_ai_context"
|
|
31
88
|
|
|
32
89
|
apply_context_mode_override
|
|
33
90
|
|
|
91
|
+
ai_tools = RailsAiContext.configuration.ai_tools
|
|
92
|
+
|
|
93
|
+
# First time — no tools configured, ask the user
|
|
94
|
+
if ai_tools.nil?
|
|
95
|
+
ai_tools = prompt_ai_tools
|
|
96
|
+
save_ai_tools_to_initializer(ai_tools) if ai_tools
|
|
97
|
+
end
|
|
98
|
+
|
|
34
99
|
puts "🔍 Introspecting #{Rails.application.class.module_parent_name}..."
|
|
35
100
|
|
|
36
|
-
|
|
37
|
-
|
|
101
|
+
if ai_tools.nil? || ai_tools.empty?
|
|
102
|
+
puts "📝 Writing context files for all AI tools..."
|
|
103
|
+
result = RailsAiContext.generate_context(format: :all)
|
|
104
|
+
print_result(result)
|
|
105
|
+
else
|
|
106
|
+
puts "📝 Writing context files for: #{ai_tools.map(&:to_s).join(', ')}..."
|
|
107
|
+
ai_tools.each do |fmt|
|
|
108
|
+
result = RailsAiContext.generate_context(format: fmt)
|
|
109
|
+
print_result(result)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
38
112
|
|
|
39
|
-
print_result(result)
|
|
40
113
|
puts ""
|
|
41
|
-
puts "Done!
|
|
42
|
-
puts "
|
|
43
|
-
puts ""
|
|
44
|
-
puts ASSISTANT_TABLE
|
|
114
|
+
puts "Done! Commit these files so your team benefits."
|
|
115
|
+
puts "Change AI tools: config/initializers/rails_ai_context.rb (config.ai_tools)"
|
|
45
116
|
end
|
|
46
117
|
|
|
47
118
|
desc "Generate AI context in a specific format (claude, cursor, windsurf, copilot, json)"
|
|
@@ -168,7 +168,19 @@ module RailsAiContext
|
|
|
168
168
|
ctrl_lines << "- `#{r[:verb]}` `#{r[:path]}` → #{r[:action]}#{helper_part}#{params_part}"
|
|
169
169
|
end
|
|
170
170
|
if ctrl_lines.any?
|
|
171
|
-
|
|
171
|
+
# Inline controller summary so AI doesn't need a separate get_controllers call
|
|
172
|
+
ctrl_class = "#{ctrl.camelize}Controller"
|
|
173
|
+
ctrl_data = cached_context.dig(:controllers, :controllers, ctrl_class)
|
|
174
|
+
ctrl_summary = ""
|
|
175
|
+
if ctrl_data
|
|
176
|
+
filters = (ctrl_data[:filters] || []).map { |f| f[:name] }.first(3)
|
|
177
|
+
formats = ctrl_data[:respond_to_formats]
|
|
178
|
+
parts = []
|
|
179
|
+
parts << "filters: #{filters.join(', ')}" if filters.any?
|
|
180
|
+
parts << "formats: #{formats.join(', ')}" if formats&.any?
|
|
181
|
+
ctrl_summary = " (#{parts.join(' | ')})" if parts.any?
|
|
182
|
+
end
|
|
183
|
+
lines << "## #{ctrl}#{ctrl_summary}"
|
|
172
184
|
lines.concat(ctrl_lines)
|
|
173
185
|
lines << ""
|
|
174
186
|
end
|
|
@@ -144,7 +144,19 @@ module RailsAiContext
|
|
|
144
144
|
hint_str = hints.any? ? " [#{hints.join(', ')}]" : ""
|
|
145
145
|
"#{c[:name]}:#{c[:type]}#{hint_str}"
|
|
146
146
|
end.join(", ")
|
|
147
|
-
|
|
147
|
+
# Inline model info so AI doesn't need a separate get_model_details call
|
|
148
|
+
model_info = ""
|
|
149
|
+
if model_refs.any?
|
|
150
|
+
model_refs.each do |mname|
|
|
151
|
+
md = models_data[mname]
|
|
152
|
+
next unless md.is_a?(Hash) && !md[:error]
|
|
153
|
+
assoc_count = md[:associations]&.size || 0
|
|
154
|
+
val_count = md[:validations]&.size || 0
|
|
155
|
+
model_info = " → **#{mname}** (#{assoc_count} assoc, #{val_count} val)"
|
|
156
|
+
break
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
lines << "### #{name}#{model_info}"
|
|
148
160
|
lines << cols
|
|
149
161
|
lines << ""
|
|
150
162
|
end
|
|
@@ -76,6 +76,11 @@ module RailsAiContext
|
|
|
76
76
|
lines << "" << "## Test Helpers"
|
|
77
77
|
data[:test_helpers].each { |h| lines << "- `#{h}`" }
|
|
78
78
|
end
|
|
79
|
+
|
|
80
|
+
# Generate a test template based on app patterns
|
|
81
|
+
template = generate_test_template(data)
|
|
82
|
+
lines.concat(template) if template.any?
|
|
83
|
+
|
|
79
84
|
text_response(lines.join("\n"))
|
|
80
85
|
|
|
81
86
|
when "full"
|
|
@@ -210,6 +215,89 @@ module RailsAiContext
|
|
|
210
215
|
"No test file found for #{name}. Searched: #{candidates.join(', ')}#{hint}"
|
|
211
216
|
end
|
|
212
217
|
|
|
218
|
+
# Generate a test template based on the app's actual test patterns
|
|
219
|
+
private_class_method def self.generate_test_template(data)
|
|
220
|
+
lines = []
|
|
221
|
+
framework = data[:framework]
|
|
222
|
+
|
|
223
|
+
if framework&.include?("RSpec") || framework&.include?("rspec")
|
|
224
|
+
lines << "" << "## Test Template (follow this pattern for new tests)"
|
|
225
|
+
lines << "```ruby"
|
|
226
|
+
lines << "require \"rails_helper\""
|
|
227
|
+
lines << ""
|
|
228
|
+
lines << "RSpec.describe ModelName, type: :model do"
|
|
229
|
+
lines << " describe \"validations\" do"
|
|
230
|
+
lines << " it { is_expected.to validate_presence_of(:field) }"
|
|
231
|
+
lines << " end"
|
|
232
|
+
lines << ""
|
|
233
|
+
lines << " describe \"#method_name\" do"
|
|
234
|
+
lines << " it \"does something\" do"
|
|
235
|
+
lines << " record = create(:model_name)"
|
|
236
|
+
lines << " expect(record.method_name).to eq(expected)"
|
|
237
|
+
lines << " end"
|
|
238
|
+
lines << " end"
|
|
239
|
+
lines << "end"
|
|
240
|
+
lines << "```"
|
|
241
|
+
else
|
|
242
|
+
# Detect Devise + sign_in pattern from existing tests
|
|
243
|
+
has_devise = false
|
|
244
|
+
has_sign_in = false
|
|
245
|
+
test_dir = Rails.root.join("test")
|
|
246
|
+
if Dir.exist?(test_dir)
|
|
247
|
+
Dir.glob(File.join(test_dir, "**/*_test.rb")).first(5).each do |path|
|
|
248
|
+
content = File.read(path, encoding: "UTF-8") rescue next
|
|
249
|
+
has_devise = true if content.include?("Devise::Test")
|
|
250
|
+
has_sign_in = true if content.include?("sign_in")
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
lines << "" << "## Test Template (follow this pattern for new tests)"
|
|
255
|
+
lines << "```ruby"
|
|
256
|
+
lines << "require \"test_helper\""
|
|
257
|
+
lines << ""
|
|
258
|
+
lines << "class ModelNameTest < ActiveSupport::TestCase"
|
|
259
|
+
lines << " test \"should be valid with required attributes\" do"
|
|
260
|
+
lines << " record = model_names(:fixture_name)"
|
|
261
|
+
lines << " assert record.valid?"
|
|
262
|
+
lines << " end"
|
|
263
|
+
lines << ""
|
|
264
|
+
lines << " test \"should require field\" do"
|
|
265
|
+
lines << " record = ModelName.new"
|
|
266
|
+
lines << " assert_not record.valid?"
|
|
267
|
+
lines << " assert_includes record.errors[:field], \"can't be blank\""
|
|
268
|
+
lines << " end"
|
|
269
|
+
lines << "end"
|
|
270
|
+
lines << "```"
|
|
271
|
+
|
|
272
|
+
if has_devise
|
|
273
|
+
lines << ""
|
|
274
|
+
lines << "```ruby"
|
|
275
|
+
lines << "# Controller test pattern"
|
|
276
|
+
lines << "require \"test_helper\""
|
|
277
|
+
lines << ""
|
|
278
|
+
lines << "class FeatureControllerTest < ActionDispatch::IntegrationTest"
|
|
279
|
+
lines << " include Devise::Test::IntegrationHelpers" if has_devise
|
|
280
|
+
lines << ""
|
|
281
|
+
lines << " test \"requires authentication\" do"
|
|
282
|
+
lines << " get feature_path"
|
|
283
|
+
lines << " assert_response :redirect"
|
|
284
|
+
lines << " end"
|
|
285
|
+
lines << ""
|
|
286
|
+
lines << " test \"shows page for signed in user\" do"
|
|
287
|
+
lines << " sign_in users(:chef_one)" if has_sign_in
|
|
288
|
+
lines << " get feature_path"
|
|
289
|
+
lines << " assert_response :success"
|
|
290
|
+
lines << " end"
|
|
291
|
+
lines << "end"
|
|
292
|
+
lines << "```"
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
lines
|
|
297
|
+
rescue
|
|
298
|
+
[]
|
|
299
|
+
end
|
|
300
|
+
|
|
213
301
|
# Parse factory file to extract attributes and traits
|
|
214
302
|
private_class_method def self.parse_factory_details(relative_path)
|
|
215
303
|
# Try common factory locations
|
|
@@ -96,12 +96,14 @@ module RailsAiContext
|
|
|
96
96
|
|
|
97
97
|
lines << "## #{ctrl}/" unless controller && all_dirs.size == 1
|
|
98
98
|
ctrl_templates.sort.each do |name, meta|
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
detail_parts = []
|
|
100
|
+
detail_parts << "renders: #{meta[:partials].join(', ')}" if meta[:partials]&.any?
|
|
101
|
+
detail_parts << "stimulus: #{meta[:stimulus].join(', ')}" if meta[:stimulus]&.any?
|
|
101
102
|
extra = extract_view_metadata(name)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
detail_parts << "ivars: #{extra[:ivars].join(', ')}" if extra[:ivars]&.any?
|
|
104
|
+
detail_parts << "turbo: #{extra[:turbo].join(', ')}" if extra[:turbo]&.any?
|
|
105
|
+
details = detail_parts.any? ? " — #{detail_parts.join(' | ')}" : ""
|
|
106
|
+
lines << "- **#{name}** (#{meta[:lines]} lines)#{details}"
|
|
105
107
|
end
|
|
106
108
|
ctrl_partials.sort.each do |name, meta|
|
|
107
109
|
fields = meta[:fields]&.any? ? " fields: #{meta[:fields].join(', ')}" : ""
|