rails-ai-context 2.0.1 → 2.0.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 +18 -0
- data/README.md +3 -2
- data/docs/GUIDE.md +22 -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 +220 -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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 28fe01be28232f19cf9fab8cb6b696630e21d663e47b426a206e771b0096bfcb
|
|
4
|
+
data.tar.gz: 488fdca01c018f6f9f468e7f03c78c2e6456e93dde0c7a89d17a5c554160b305
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bd0017a556c8a728b58ac5b18dc52682a825602e295be6a54f8bde15baf552d62247d57441af936d8762d00763aec076e46dc52d745274bf74362df5105bb0ec
|
|
7
|
+
data.tar.gz: e16d3d1306ad9d4aa617a72e0311cbf1ffb8f2f5e80074913e3c1be62ceca2f8325642e3cf1515c6c14a930349b2f5a78a98f913d7c53f011bf3e1b6d7450eaa
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.0.2] - 2026-03-25
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`match_type:"trace"` in search_code** — full method picture in one call: definition + source code + all callers grouped by type (Controller/Model/View/Job/Service/Test) + internal calls. The game changer for code navigation.
|
|
13
|
+
- **`match_type:"call"`** — find call sites only, excluding definitions.
|
|
14
|
+
- **Smart result limiting** — <10 shows all, 10-100 shows half, >100 caps at 100. Pagination via `offset:` param.
|
|
15
|
+
- **`exclude_tests:true`** — skip test/spec/features directories in search results.
|
|
16
|
+
- **`group_by_file:true`** — group search results by file with match counts.
|
|
17
|
+
- **Inline cross-references** — schema shows model name + association count per table, routes show controller filters inline, views use pipe-separated metadata.
|
|
18
|
+
- **Test template generation** — `get_test_info(detail:"standard")` includes a copy-paste test template matching the app's patterns (Minitest/RSpec, Devise sign_in, fixtures).
|
|
19
|
+
- **Interactive AI tool selection** — install generator and `rails ai:context` prompt users to select which AI tools they use (Claude, Cursor, Copilot, Windsurf, OpenCode). Selection saved to `config.ai_tools`.
|
|
20
|
+
- **Brakeman in validate** — `rails_validate(level:"rails")` now runs Brakeman security checks inline alongside syntax and semantic checks.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **Documentation audit** — fixed max_tool_response_chars reference (120K→200K), added missing search_code params to GUIDE, added config.ai_tools to config reference.
|
|
25
|
+
|
|
8
26
|
## [2.0.1] - 2026-03-25
|
|
9
27
|
|
|
10
28
|
### Fixed
|
data/README.md
CHANGED
|
@@ -111,7 +111,7 @@ The gem exposes **25 read-only tools** via MCP that AI clients call on-demand:
|
|
|
111
111
|
| `rails_get_test_info` | Test framework, factory attributes/traits, fixtures, CI config, coverage |
|
|
112
112
|
| `rails_get_gems` | Notable gems categorized by function with config location hints |
|
|
113
113
|
| `rails_get_conventions` | Architecture patterns, frontend stack, directory structure |
|
|
114
|
-
| `rails_search_code` | Ripgrep search with
|
|
114
|
+
| `rails_search_code` | Ripgrep search with `match_type:"trace"` (definition + callers + internal calls in one shot), `"definition"`, `"call"`, `"class"` filters, smart result limiting, pagination |
|
|
115
115
|
| `rails_get_view` | View templates, partials with render locals, Stimulus references |
|
|
116
116
|
| `rails_get_stimulus` | Stimulus controllers — targets, values, actions, outlets, lifecycle methods |
|
|
117
117
|
| `rails_get_edit_context` | Surgical edit helper — returns code with class/method context and line numbers |
|
|
@@ -146,7 +146,7 @@ rails_get_routes(controller: "users") # → routes for one controller
|
|
|
146
146
|
rails_get_model_details(model: "User") # → associations, validations, scopes
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
-
A safety net (`max_tool_response_chars`, default
|
|
149
|
+
A safety net (`max_tool_response_chars`, default 200K) truncates oversized responses with hints to use filters.
|
|
150
150
|
|
|
151
151
|
---
|
|
152
152
|
|
|
@@ -351,6 +351,7 @@ end
|
|
|
351
351
|
| **Extensibility** | | |
|
|
352
352
|
| `custom_tools` | `[]` | Additional MCP tool classes to register alongside built-in tools |
|
|
353
353
|
| `skip_tools` | `[]` | Built-in tool names to exclude (e.g. `%w[rails_security_scan]`) |
|
|
354
|
+
| `ai_tools` | `nil` (all) | AI tools to generate context for: `%i[claude cursor copilot windsurf opencode]` |
|
|
354
355
|
</details>
|
|
355
356
|
|
|
356
357
|
---
|
data/docs/GUIDE.md
CHANGED
|
@@ -561,31 +561,38 @@ 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 + source + callers + internal calls). |
|
|
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 + source code + all callers grouped by type + internal calls
|
|
581
|
+
|
|
582
|
+
rails_search_code(pattern: "create", match_type: "definition")
|
|
583
|
+
→ Only `def create` / `def self.create` lines
|
|
577
584
|
|
|
578
|
-
rails_search_code(pattern: "
|
|
579
|
-
→
|
|
585
|
+
rails_search_code(pattern: "can_cook", match_type: "call")
|
|
586
|
+
→ Only call sites (excludes the definition)
|
|
580
587
|
|
|
581
|
-
rails_search_code(pattern: "
|
|
582
|
-
→
|
|
588
|
+
rails_search_code(pattern: "Controller", match_type: "class")
|
|
589
|
+
→ All class/module definitions matching *Controller
|
|
583
590
|
|
|
584
|
-
rails_search_code(pattern: "
|
|
585
|
-
→
|
|
591
|
+
rails_search_code(pattern: "has_many", group_by_file: true)
|
|
592
|
+
→ Results grouped by file with match counts
|
|
586
593
|
|
|
587
|
-
rails_search_code(pattern: "
|
|
588
|
-
→
|
|
594
|
+
rails_search_code(pattern: "cook", exclude_tests: true)
|
|
595
|
+
→ Skip test/spec directories
|
|
589
596
|
|
|
590
597
|
rails_search_code(pattern: "activate", match_type: "definition")
|
|
591
598
|
→ Only `def activate` / `def self.activate` lines (skips method calls)
|
|
@@ -1175,6 +1182,7 @@ end
|
|
|
1175
1182
|
| `cache_ttl` | Integer | `60` | Cache TTL in seconds for introspection results |
|
|
1176
1183
|
| `custom_tools` | Array | `[]` | Additional MCP tool classes to register alongside built-in tools |
|
|
1177
1184
|
| `skip_tools` | Array | `[]` | Built-in tool names to exclude (e.g. `%w[rails_security_scan]`) |
|
|
1185
|
+
| `ai_tools` | Array | `nil` (all) | AI tools to generate context for: `%i[claude cursor copilot windsurf opencode]`. Selected during install. |
|
|
1178
1186
|
| `excluded_models` | Array | internal Rails models | Models to skip |
|
|
1179
1187
|
| `excluded_paths` | Array | `node_modules tmp log vendor .git` | Paths excluded from code search |
|
|
1180
1188
|
| `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(', ')}" : ""
|
|
@@ -6,24 +6,15 @@ module RailsAiContext
|
|
|
6
6
|
module Tools
|
|
7
7
|
class SearchCode < BaseTool
|
|
8
8
|
tool_name "rails_search_code"
|
|
9
|
-
description "Search the Rails codebase
|
|
10
|
-
"Use
|
|
11
|
-
"
|
|
9
|
+
description "Search the Rails codebase with smart modes. " \
|
|
10
|
+
"Use match_type:\"trace\" to see where a method is defined, who calls it, and what it calls — in one call. " \
|
|
11
|
+
"Use match_type:\"definition\" for definitions only, \"call\" for call sites only, \"class\" for class/module definitions. " \
|
|
12
|
+
"Requires pattern:\"method_name\". Narrow with path:\"app/models\" and file_type:\"rb\"."
|
|
12
13
|
|
|
13
14
|
def self.max_results_cap
|
|
14
15
|
RailsAiContext.configuration.max_search_results
|
|
15
16
|
end
|
|
16
17
|
|
|
17
|
-
# Non-code files excluded from all searches — lock files, docs, generated context, config meta
|
|
18
|
-
NON_CODE_GLOBS = %w[
|
|
19
|
-
*.lock package-lock.json yarn.lock pnpm-lock.yaml bun.lockb
|
|
20
|
-
*.md LICENSE* CHANGELOG* CONTRIBUTING*
|
|
21
|
-
CLAUDE.md AGENTS.md .cursorrules .cursor/ .claude/
|
|
22
|
-
Dockerfile* docker-compose*
|
|
23
|
-
.rubocop.yml .ruby-version .node-version .tool-versions
|
|
24
|
-
.github/ .circleci/ .gitlab-ci.yml
|
|
25
|
-
].freeze
|
|
26
|
-
|
|
27
18
|
input_schema(
|
|
28
19
|
properties: {
|
|
29
20
|
pattern: {
|
|
@@ -40,16 +31,24 @@ module RailsAiContext
|
|
|
40
31
|
},
|
|
41
32
|
match_type: {
|
|
42
33
|
type: "string",
|
|
43
|
-
enum: %w[any definition class],
|
|
44
|
-
description: "
|
|
34
|
+
enum: %w[any definition class call trace],
|
|
35
|
+
description: "any: all matches (default). definition: `def` lines only. class: `class/module` lines. call: call sites only (excludes definitions). trace: FULL PICTURE — shows definition + source code + all callers + what it calls internally."
|
|
45
36
|
},
|
|
46
37
|
exact_match: {
|
|
47
38
|
type: "boolean",
|
|
48
39
|
description: "Match whole words only (wraps pattern in \\b word boundaries). Default: false."
|
|
49
40
|
},
|
|
50
|
-
|
|
41
|
+
exclude_tests: {
|
|
42
|
+
type: "boolean",
|
|
43
|
+
description: "Exclude test/spec files from results. Default: false."
|
|
44
|
+
},
|
|
45
|
+
group_by_file: {
|
|
46
|
+
type: "boolean",
|
|
47
|
+
description: "Group results by file with match counts. Default: false."
|
|
48
|
+
},
|
|
49
|
+
offset: {
|
|
51
50
|
type: "integer",
|
|
52
|
-
description: "
|
|
51
|
+
description: "Skip this many results for pagination. Default: 0."
|
|
53
52
|
},
|
|
54
53
|
context_lines: {
|
|
55
54
|
type: "integer",
|
|
@@ -61,31 +60,40 @@ module RailsAiContext
|
|
|
61
60
|
|
|
62
61
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
63
62
|
|
|
64
|
-
def self.call(pattern:, path: nil, file_type: nil, match_type: "any", exact_match: false,
|
|
63
|
+
def self.call(pattern:, path: nil, file_type: nil, match_type: "any", exact_match: false, exclude_tests: false, group_by_file: false, offset: 0, context_lines: 2, server_context: nil) # rubocop:disable Metrics
|
|
65
64
|
root = Rails.root.to_s
|
|
65
|
+
original_pattern = pattern
|
|
66
66
|
|
|
67
67
|
# Reject empty or whitespace-only patterns
|
|
68
68
|
if pattern.nil? || pattern.strip.empty?
|
|
69
69
|
return text_response("Pattern is required. Provide a search term or regex.")
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
+
# Trace mode — the game changer: full method picture in one call
|
|
73
|
+
if match_type == "trace"
|
|
74
|
+
return trace_method(pattern.strip, root, path, exclude_tests)
|
|
75
|
+
end
|
|
76
|
+
|
|
72
77
|
# Apply exact_match word boundaries
|
|
73
78
|
pattern = "\\b#{pattern}\\b" if exact_match
|
|
74
79
|
|
|
75
|
-
# Apply match_type filter to pattern
|
|
76
|
-
|
|
80
|
+
# Apply match_type filter to pattern
|
|
81
|
+
search_pattern = case match_type
|
|
77
82
|
when "definition"
|
|
78
83
|
cleaned = pattern.sub(/\A\s*def\s+/, "")
|
|
79
84
|
"^\\s*def\\s+(self\\.)?#{cleaned}"
|
|
80
85
|
when "class"
|
|
81
86
|
cleaned = pattern.sub(/\A\s*(class|module)\s+/, "")
|
|
82
|
-
"^\\s*(class|module)\\s
|
|
83
|
-
|
|
87
|
+
"^\\s*(class|module)\\s+\\w*#{cleaned}"
|
|
88
|
+
when "call"
|
|
89
|
+
pattern
|
|
90
|
+
else
|
|
91
|
+
pattern
|
|
84
92
|
end
|
|
85
93
|
|
|
86
94
|
# Validate regex syntax early
|
|
87
95
|
begin
|
|
88
|
-
Regexp.new(
|
|
96
|
+
Regexp.new(search_pattern, timeout: 1)
|
|
89
97
|
rescue RegexpError => e
|
|
90
98
|
return text_response("Invalid regex pattern: #{e.message}")
|
|
91
99
|
end
|
|
@@ -95,10 +103,8 @@ module RailsAiContext
|
|
|
95
103
|
return text_response("Invalid file_type: must contain only alphanumeric characters.")
|
|
96
104
|
end
|
|
97
105
|
|
|
98
|
-
# Cap max_results and context_lines
|
|
99
|
-
max_results = [ max_results.to_i, max_results_cap ].min
|
|
100
|
-
max_results = 30 if max_results < 1
|
|
101
106
|
context_lines = [ [ context_lines.to_i, 0 ].max, 5 ].min
|
|
107
|
+
offset = [ offset.to_i, 0 ].max
|
|
102
108
|
|
|
103
109
|
search_path = path ? File.join(root, path) : root
|
|
104
110
|
|
|
@@ -118,28 +124,60 @@ module RailsAiContext
|
|
|
118
124
|
return text_response("Path not found: #{path}")
|
|
119
125
|
end
|
|
120
126
|
|
|
121
|
-
results
|
|
122
|
-
|
|
127
|
+
# Fetch all results (capped at 200 for safety)
|
|
128
|
+
all_results = if ripgrep_available?
|
|
129
|
+
search_with_ripgrep(search_pattern, search_path, file_type, max_results_cap, root, context_lines, exclude_tests: exclude_tests)
|
|
123
130
|
else
|
|
124
|
-
|
|
131
|
+
search_with_ruby(search_pattern, search_path, file_type, max_results_cap, root, exclude_tests: exclude_tests)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Filter out definitions for match_type:"call"
|
|
135
|
+
all_results.reject! { |r| r[:content].match?(/\A\s*def\s/) } if match_type == "call"
|
|
136
|
+
|
|
137
|
+
if all_results.empty?
|
|
138
|
+
return text_response("No results found for '#{original_pattern}' in #{path || 'app'}.")
|
|
125
139
|
end
|
|
126
140
|
|
|
127
|
-
|
|
128
|
-
|
|
141
|
+
# Smart result limiting:
|
|
142
|
+
# <10 total → show all, 10-100 → show half, >100 → cap at 100
|
|
143
|
+
total = all_results.size
|
|
144
|
+
show_count = if total <= 10 then total
|
|
145
|
+
elsif total <= 100 then (total / 2.0).ceil
|
|
146
|
+
else 100
|
|
129
147
|
end
|
|
130
148
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
footer = "\n```"
|
|
149
|
+
# Apply pagination
|
|
150
|
+
paginated = all_results.drop(offset).first(show_count)
|
|
134
151
|
|
|
135
|
-
|
|
152
|
+
if paginated.empty? && total > 0
|
|
153
|
+
return text_response("No results at offset #{offset}. Total: #{total}. Use `offset:0`.")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Build header with total count and pagination info
|
|
157
|
+
showing = offset > 0 ? "#{offset + 1}-#{offset + paginated.size}" : "#{paginated.size}"
|
|
158
|
+
pagination = if offset + paginated.size < total
|
|
159
|
+
"\n_Showing #{showing} of #{total}. Use `offset:#{offset + paginated.size}` for more._"
|
|
160
|
+
elsif total > paginated.size
|
|
161
|
+
"\n_Showing #{showing} of #{total}._"
|
|
162
|
+
else
|
|
163
|
+
""
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
header = "# Search: `#{original_pattern}`\n**#{total} total results**#{" in #{path}" if path}, showing #{showing}\n"
|
|
167
|
+
|
|
168
|
+
if group_by_file
|
|
169
|
+
text_response(header + "\n" + format_grouped(paginated) + pagination)
|
|
170
|
+
else
|
|
171
|
+
output = paginated.map { |r| "#{r[:file]}:#{r[:line_number]}: #{r[:content].strip}" }.join("\n")
|
|
172
|
+
text_response("#{header}\n```\n#{output}\n```#{pagination}")
|
|
173
|
+
end
|
|
136
174
|
end
|
|
137
175
|
|
|
138
176
|
private_class_method def self.ripgrep_available?
|
|
139
177
|
@rg_available ||= system("which rg > /dev/null 2>&1")
|
|
140
178
|
end
|
|
141
179
|
|
|
142
|
-
private_class_method def self.search_with_ripgrep(pattern, search_path, file_type, max_results, root, ctx_lines = 0)
|
|
180
|
+
private_class_method def self.search_with_ripgrep(pattern, search_path, file_type, max_results, root, ctx_lines = 0, exclude_tests: false)
|
|
143
181
|
cmd = [ "rg", "--no-heading", "--line-number", "--sort=path", "--max-count", max_results.to_s ]
|
|
144
182
|
if ctx_lines > 0
|
|
145
183
|
cmd.push("-C", ctx_lines.to_s)
|
|
@@ -157,9 +195,12 @@ module RailsAiContext
|
|
|
157
195
|
cmd << "--glob=!#{p}"
|
|
158
196
|
end
|
|
159
197
|
|
|
160
|
-
# Exclude
|
|
161
|
-
|
|
162
|
-
|
|
198
|
+
# Exclude test/spec directories if requested
|
|
199
|
+
if exclude_tests
|
|
200
|
+
cmd << "--glob=!test/"
|
|
201
|
+
cmd << "--glob=!spec/"
|
|
202
|
+
cmd << "--glob=!features/"
|
|
203
|
+
end
|
|
163
204
|
|
|
164
205
|
if file_type
|
|
165
206
|
cmd.push("--type-add", "custom:*.#{file_type}", "--type", "custom")
|
|
@@ -178,7 +219,7 @@ module RailsAiContext
|
|
|
178
219
|
[ { file: "error", line_number: 0, content: e.message } ]
|
|
179
220
|
end
|
|
180
221
|
|
|
181
|
-
private_class_method def self.search_with_ruby(pattern, search_path, file_type, max_results, root)
|
|
222
|
+
private_class_method def self.search_with_ruby(pattern, search_path, file_type, max_results, root, exclude_tests: false)
|
|
182
223
|
results = []
|
|
183
224
|
begin
|
|
184
225
|
regex = Regexp.new(pattern, Regexp::IGNORECASE, timeout: 2)
|
|
@@ -189,11 +230,13 @@ module RailsAiContext
|
|
|
189
230
|
glob = file_type ? "**/*.#{file_type}" : "**/*.{#{extensions}}"
|
|
190
231
|
excluded = RailsAiContext.configuration.excluded_paths
|
|
191
232
|
sensitive = RailsAiContext.configuration.sensitive_patterns
|
|
233
|
+
test_dirs = %w[test/ spec/ features/]
|
|
192
234
|
|
|
193
235
|
Dir.glob(File.join(search_path, glob)).each do |file|
|
|
194
236
|
relative = file.sub("#{root}/", "")
|
|
195
237
|
next if excluded.any? { |ex| relative.start_with?(ex) }
|
|
196
238
|
next if sensitive_file?(relative, sensitive)
|
|
239
|
+
next if exclude_tests && test_dirs.any? { |td| relative.start_with?(td) }
|
|
197
240
|
|
|
198
241
|
File.readlines(file).each_with_index do |line, idx|
|
|
199
242
|
if line.match?(regex)
|
|
@@ -216,6 +259,20 @@ module RailsAiContext
|
|
|
216
259
|
end
|
|
217
260
|
end
|
|
218
261
|
|
|
262
|
+
# Group results by file for cleaner output
|
|
263
|
+
private_class_method def self.format_grouped(results)
|
|
264
|
+
grouped = results.group_by { |r| r[:file] }
|
|
265
|
+
lines = []
|
|
266
|
+
grouped.each do |file, matches|
|
|
267
|
+
lines << "## #{file} (#{matches.size} matches)"
|
|
268
|
+
lines << "```"
|
|
269
|
+
matches.each { |r| lines << "#{r[:line_number]}: #{r[:content].strip}" }
|
|
270
|
+
lines << "```"
|
|
271
|
+
lines << ""
|
|
272
|
+
end
|
|
273
|
+
lines.join("\n")
|
|
274
|
+
end
|
|
275
|
+
|
|
219
276
|
private_class_method def self.parse_rg_output(output, root)
|
|
220
277
|
output.lines.filter_map do |line|
|
|
221
278
|
next if line.strip == "--" # Skip group separators from -C context output
|
|
@@ -229,6 +286,129 @@ module RailsAiContext
|
|
|
229
286
|
}
|
|
230
287
|
end
|
|
231
288
|
end
|
|
289
|
+
|
|
290
|
+
# ── Trace Mode — the game changer ──────────────────────────────
|
|
291
|
+
# Shows definition + source + callers + internal calls in one response
|
|
292
|
+
|
|
293
|
+
private_class_method def self.trace_method(method_name, root, path, exclude_tests) # rubocop:disable Metrics
|
|
294
|
+
# Clean input: strip "def ", "self.", parens
|
|
295
|
+
cleaned = method_name.sub(/\A\s*def\s+/, "").sub(/\Aself\./, "").sub(/\(.*/, "").strip
|
|
296
|
+
return text_response("Provide a method name to trace.") if cleaned.empty?
|
|
297
|
+
|
|
298
|
+
search_path = path ? File.join(root, path) : root
|
|
299
|
+
lines = [ "# Trace: `#{cleaned}`", "" ]
|
|
300
|
+
|
|
301
|
+
# 1. Find the definition (no \b after ? or ! since they ARE word boundaries)
|
|
302
|
+
def_pattern = "^\\s*def\\s+(self\\.)?#{Regexp.escape(cleaned)}"
|
|
303
|
+
def_pattern += "\\b" unless cleaned.end_with?("?") || cleaned.end_with?("!")
|
|
304
|
+
def_results = quick_search(def_pattern, search_path, root, 10, exclude_tests)
|
|
305
|
+
|
|
306
|
+
if def_results.any?
|
|
307
|
+
lines << "## Definition"
|
|
308
|
+
def_results.each do |r|
|
|
309
|
+
lines << "**#{r[:file]}:#{r[:line_number]}**"
|
|
310
|
+
# Extract the full method body
|
|
311
|
+
body = extract_method_body(File.join(root, r[:file]), r[:line_number])
|
|
312
|
+
if body
|
|
313
|
+
lines << "```ruby"
|
|
314
|
+
lines << body
|
|
315
|
+
lines << "```"
|
|
316
|
+
|
|
317
|
+
# 3. What does this method call? (extract method-like calls from body)
|
|
318
|
+
internal_calls = body.scan(/\b([a-z_]\w*[!?]?)(?:\s*[\(])/).flatten.uniq
|
|
319
|
+
internal_calls += body.scan(/\b([A-Z]\w+(?:::\w+)*)\.(new|call|perform_later|perform_async|find|where|create)/).map { |c| "#{c[0]}.#{c[1]}" }
|
|
320
|
+
internal_calls.reject! { |c| %w[if else elsif unless return end def class module do begin rescue ensure raise puts print].include?(c) }
|
|
321
|
+
internal_calls.reject! { |c| c == cleaned }
|
|
322
|
+
|
|
323
|
+
if internal_calls.any?
|
|
324
|
+
lines << "" << "## Calls internally"
|
|
325
|
+
internal_calls.first(15).each { |c| lines << "- `#{c}`" }
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
lines << ""
|
|
329
|
+
end
|
|
330
|
+
else
|
|
331
|
+
lines << "_No definition found for `def #{cleaned}`_"
|
|
332
|
+
lines << ""
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# 2. Find all callers (everywhere the method is referenced, excluding the def line)
|
|
336
|
+
call_pattern = if cleaned.end_with?("?") || cleaned.end_with?("!")
|
|
337
|
+
"#{Regexp.escape(cleaned)}"
|
|
338
|
+
else
|
|
339
|
+
"\\b#{Regexp.escape(cleaned)}\\b"
|
|
340
|
+
end
|
|
341
|
+
call_results = quick_search(call_pattern, search_path, root, max_results_cap, exclude_tests)
|
|
342
|
+
callers = call_results.reject { |r| r[:content].match?(/\A\s*def\s/) }
|
|
343
|
+
|
|
344
|
+
# Exclude the definition file+line to avoid self-reference
|
|
345
|
+
def_locations = def_results.map { |r| "#{r[:file]}:#{r[:line_number]}" }.to_set
|
|
346
|
+
callers.reject! { |r| def_locations.include?("#{r[:file]}:#{r[:line_number]}") }
|
|
347
|
+
|
|
348
|
+
if callers.any?
|
|
349
|
+
lines << "## Called from (#{callers.size} sites)"
|
|
350
|
+
|
|
351
|
+
# Group by file for readability
|
|
352
|
+
grouped = callers.group_by { |r| r[:file] }
|
|
353
|
+
grouped.each do |file, matches|
|
|
354
|
+
# Categorize the file
|
|
355
|
+
category = case file
|
|
356
|
+
when /controller/i then "Controller"
|
|
357
|
+
when /model/i then "Model"
|
|
358
|
+
when /view|\.erb/i then "View"
|
|
359
|
+
when /job/i then "Job"
|
|
360
|
+
when /service/i then "Service"
|
|
361
|
+
when /test|spec/i then "Test"
|
|
362
|
+
when /\.js$|\.ts$/i then "JavaScript"
|
|
363
|
+
else "Other"
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
lines << "### #{file} (#{category})"
|
|
367
|
+
matches.first(5).each do |r|
|
|
368
|
+
lines << " #{r[:line_number]}: #{r[:content].strip}"
|
|
369
|
+
end
|
|
370
|
+
lines << " _(#{matches.size - 5} more)_" if matches.size > 5
|
|
371
|
+
end
|
|
372
|
+
else
|
|
373
|
+
lines << "## Called from"
|
|
374
|
+
lines << "_No call sites found (method may be unused or called dynamically)_"
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
text_response(lines.join("\n"))
|
|
378
|
+
rescue => e
|
|
379
|
+
text_response("Trace error: #{e.message}")
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Fast ripgrep search for trace mode (no formatting, just results)
|
|
383
|
+
private_class_method def self.quick_search(pattern, search_path, root, limit, exclude_tests)
|
|
384
|
+
if ripgrep_available?
|
|
385
|
+
search_with_ripgrep(pattern, search_path, nil, limit, root, 0, exclude_tests: exclude_tests)
|
|
386
|
+
else
|
|
387
|
+
search_with_ruby(pattern, search_path, nil, limit, root, exclude_tests: exclude_tests)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Extract a method body from a file given the def line number
|
|
392
|
+
private_class_method def self.extract_method_body(file_path, def_line)
|
|
393
|
+
return nil unless File.exist?(file_path)
|
|
394
|
+
return nil if File.size(file_path) > RailsAiContext.configuration.max_file_size
|
|
395
|
+
|
|
396
|
+
source_lines = File.readlines(file_path)
|
|
397
|
+
start_idx = def_line - 1
|
|
398
|
+
return nil if start_idx >= source_lines.size
|
|
399
|
+
|
|
400
|
+
def_indent = source_lines[start_idx][/\A\s*/].length
|
|
401
|
+
result = [ source_lines[start_idx].rstrip ]
|
|
402
|
+
|
|
403
|
+
source_lines[(start_idx + 1)..].each do |line|
|
|
404
|
+
result << line.rstrip
|
|
405
|
+
break if line.match?(/\A\s{#{def_indent}}end\b/)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
result.join("\n")
|
|
409
|
+
rescue
|
|
410
|
+
nil
|
|
411
|
+
end
|
|
232
412
|
end
|
|
233
413
|
end
|
|
234
414
|
end
|
|
@@ -95,6 +95,12 @@ module RailsAiContext
|
|
|
95
95
|
end
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
+
# Run Brakeman security scan on validated files (if installed and level:"rails")
|
|
99
|
+
if level == "rails"
|
|
100
|
+
brakeman_warnings = check_brakeman_security(files)
|
|
101
|
+
brakeman_warnings.each { |w| results << " \u26A0 #{w}" }
|
|
102
|
+
end
|
|
103
|
+
|
|
98
104
|
output = results.join("\n")
|
|
99
105
|
output += "\n\n#{passed}/#{total} files passed"
|
|
100
106
|
output += " _(Prism unavailable — using fallback parser, some semantic checks skipped)_" unless prism_available?
|
|
@@ -950,6 +956,48 @@ module RailsAiContext
|
|
|
950
956
|
rescue
|
|
951
957
|
[]
|
|
952
958
|
end
|
|
959
|
+
|
|
960
|
+
# ── Brakeman security scan (runs once for all files) ───────────
|
|
961
|
+
|
|
962
|
+
private_class_method def self.check_brakeman_security(files)
|
|
963
|
+
return [] unless brakeman_available?
|
|
964
|
+
|
|
965
|
+
tracker = Brakeman.run(
|
|
966
|
+
app_path: Rails.root.to_s,
|
|
967
|
+
quiet: true,
|
|
968
|
+
report_progress: false,
|
|
969
|
+
print_report: false
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
warnings = tracker.filtered_warnings
|
|
973
|
+
return [] if warnings.empty?
|
|
974
|
+
|
|
975
|
+
# Filter to only warnings in the validated files
|
|
976
|
+
normalized = files.map { |f| f.delete_prefix("/") }
|
|
977
|
+
relevant = warnings.select do |w|
|
|
978
|
+
path = w.file.relative
|
|
979
|
+
normalized.any? { |f| path == f || path.start_with?(f) }
|
|
980
|
+
end
|
|
981
|
+
return [] if relevant.empty?
|
|
982
|
+
|
|
983
|
+
relevant.sort_by(&:confidence).first(5).map do |w|
|
|
984
|
+
loc = w.line ? "#{w.file.relative}:#{w.line}" : w.file.relative
|
|
985
|
+
"[#{w.confidence_name}] #{w.warning_type} — #{loc}: #{w.message}"
|
|
986
|
+
end
|
|
987
|
+
rescue
|
|
988
|
+
[]
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
private_class_method def self.brakeman_available?
|
|
992
|
+
return @brakeman_available unless @brakeman_available.nil?
|
|
993
|
+
|
|
994
|
+
@brakeman_available = begin
|
|
995
|
+
require "brakeman"
|
|
996
|
+
true
|
|
997
|
+
rescue LoadError
|
|
998
|
+
false
|
|
999
|
+
end
|
|
1000
|
+
end
|
|
953
1001
|
end
|
|
954
1002
|
end
|
|
955
1003
|
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": "2.0.
|
|
10
|
+
"version": "2.0.2",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "mcpb",
|
|
14
|
-
"identifier": "https://github.com/crisnahine/rails-ai-context/releases/download/v2.0.
|
|
14
|
+
"identifier": "https://github.com/crisnahine/rails-ai-context/releases/download/v2.0.2/rails-ai-context-mcp.mcpb",
|
|
15
15
|
"fileSha256": "dd711a0ad6c4de943ae4da94eaf59a6dc9494b9d57f726e24649ed4e2f156990",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|