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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 485ed8bdee695a10d5fd05b9a578bb3ff55f918d5ea9d3ddd3a18ba56f03c3f7
4
- data.tar.gz: 010af0cd0f8958e09856f08e6be2c54d87f75ab6ea7a1e3c2993523a15d18992
3
+ metadata.gz: 28fe01be28232f19cf9fab8cb6b696630e21d663e47b426a206e771b0096bfcb
4
+ data.tar.gz: 488fdca01c018f6f9f468e7f03c78c2e6456e93dde0c7a89d17a5c554160b305
5
5
  SHA512:
6
- metadata.gz: 66aced48319fe77c7d5301ca154150db872d5ee682be94c5ae81af229901fb3ac950e932ce984f0f5c13d4dbfceae5f89bcb2fbe479c04ab38ec1a6fb605d7a2
7
- data.tar.gz: 81cbff9c0f81ef41d1c1db9b9b97a6af606da9915c59214097dfe8386d7437250f4e6c470b59ca3c1dd8721bf21a458bbe6cf351313ff7a47cfaaab90fabb221
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 2-line context default, `match_type:"definition"` for method defs only |
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 120K) truncates oversized responses with hints to use filters.
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 type (e.g. `rb`, `erb`, `js`). Alphanumeric only. |
567
- | `match_type` | string | Filter matches: `any` (default), `definition` (only `def` lines), `class` (only `class`/`module` lines). |
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
- | `max_results` | integer | Max results to return. Default: 30, max: 100. |
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: "has_secure_password")
576
- All files containing has_secure_password
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: "class.*Controller", file_type: "rb")
579
- All Ruby files with controller class definitions
585
+ rails_search_code(pattern: "can_cook", match_type: "call")
586
+ Only call sites (excludes the definition)
580
587
 
581
- rails_search_code(pattern: "def create", file_type: "rb", max_results: 50)
582
- First 50 create methods across the codebase
588
+ rails_search_code(pattern: "Controller", match_type: "class")
589
+ All class/module definitions matching *Controller
583
590
 
584
- rails_search_code(pattern: "current_user", path: "app/controllers")
585
- Search only in app/controllers/
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: "validates", context_lines: 3)
588
- Matches with 3 lines of context before and after (default is 2)
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 — schema, models, routes, views, turbo, auth, API, assets, devops, etc.)
45
- # :standard — 13 core introspectors (schema, models, routes, jobs, gems, conventions, controllers, tests, migrations, stimulus, view_templates, design_tokens, config)
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
- # Paths to exclude from code search
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
- if Rails.application
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 "Quick start:", :yellow
124
- say " rails ai:context # Generate all context files"
125
- say " rails ai:context:claude # Generate CLAUDE.md only"
126
- say " rails ai:context:cursor # Generate .cursor/rules/ only"
127
- say " rails ai:serve # Start MCP server (stdio)"
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 "Generated files per AI tool:", :yellow
131
- say " Claude Code → CLAUDE.md + .claude/rules/*.md"
132
- say " OpenCode → AGENTS.md"
133
- say " Cursor → .cursor/rules/*.mdc"
134
- say " Windsurf → .windsurfrules + .windsurf/rules/*.md"
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 MCP config needed — just open your project."
169
+ say " No manual config needed — just open your project."
140
170
  say ""
141
- say "Context modes:", :yellow
142
- say " rails ai:context # compact mode (default, smart for any app size)"
143
- say " rails ai:context:full # full dump (good for small apps)"
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 (CLAUDE.md, .cursor/rules/, .windsurfrules, .github/copilot-instructions.md)"
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
- puts "📝 Writing context files..."
37
- result = RailsAiContext.generate_context(format: :all)
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! Your AI assistants now understand your Rails app."
42
- puts "Commit these files so your whole team benefits."
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
- lines << "## #{ctrl}"
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
- lines << "### #{name}"
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
- parts = meta[:partials]&.any? ? " renders: #{meta[:partials].join(', ')}" : ""
100
- stim = meta[:stimulus]&.any? ? " stimulus: #{meta[:stimulus].join(', ')}" : ""
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
- ivars = extra[:ivars]&.any? ? " ivars: #{extra[:ivars].join(', ')}" : ""
103
- turbo = extra[:turbo]&.any? ? " turbo: #{extra[:turbo].join(', ')}" : ""
104
- lines << "- #{name} (#{meta[:lines]} lines)#{parts}#{stim}#{ivars}#{turbo}"
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 by regex pattern, returning matching lines with file paths and line numbers. " \
10
- "Use when: finding where a method is called, locating class definitions, or tracing how a feature is implemented. " \
11
- "Requires pattern:\"def activate\". Narrow with path:\"app/models\" and file_type:\"rb\"."
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: "Filter match type. any: all matches (default). definition: only `def method_name` lines. class: only `class/module Name` lines."
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
- max_results: {
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: "Maximum number of results. Default: 30, max: 200."
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, max_results: 30, context_lines: 2, server_context: nil)
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 (strip keyword if user already included it)
76
- pattern = case match_type
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+#{cleaned}"
83
- else pattern
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(pattern, timeout: 1)
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 = if ripgrep_available?
122
- search_with_ripgrep(pattern, search_path, file_type, max_results, root, context_lines)
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
- search_with_ruby(pattern, search_path, file_type, max_results, root)
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
- if results.empty?
128
- return text_response("No results found for '#{pattern}' in #{path || 'app'}.")
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
- output = results.map { |r| "#{r[:file]}:#{r[:line_number]}: #{r[:content].strip}" }.join("\n")
132
- header = "# Search: `#{pattern}`\n**#{results.size} results**#{" in #{path}" if path}\n\n```\n"
133
- footer = "\n```"
149
+ # Apply pagination
150
+ paginated = all_results.drop(offset).first(show_count)
134
151
 
135
- text_response("#{header}#{output}#{footer}")
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 non-code files that generate noise in search results
161
- # (excluded_paths already handles node_modules, tmp, log, vendor, .git)
162
- NON_CODE_GLOBS.each { |glob| cmd << "--glob=!#{glob}" }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "2.0.1"
4
+ VERSION = "2.0.2"
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": "2.0.1",
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.1/rails-ai-context-mcp.mcpb",
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"
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: 2.0.1
4
+ version: 2.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine