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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -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/serializers/claude_rules_serializer.rb +45 -23
- data/lib/rails_ai_context/serializers/claude_serializer.rb +40 -24
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +29 -27
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +29 -27
- data/lib/rails_ai_context/serializers/opencode_serializer.rb +31 -25
- data/lib/rails_ai_context/serializers/windsurf_rules_serializer.rb +26 -25
- 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
|
@@ -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"
|