rails-ai-context 2.0.0 → 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.
@@ -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.0"
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.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.0/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.0
4
+ version: 2.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine