rails-ai-context 4.2.2 → 4.3.0
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 +30 -0
- data/CLAUDE.md +4 -4
- data/CONTRIBUTING.md +1 -1
- data/README.md +7 -7
- data/SECURITY.md +1 -1
- data/docs/GUIDE.md +3 -3
- data/lib/generators/rails_ai_context/install/install_generator.rb +2 -2
- data/lib/rails_ai_context/doctor.rb +8 -1
- data/lib/rails_ai_context/introspectors/model_introspector.rb +1 -1
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -1
- data/lib/rails_ai_context/serializers/claude_serializer.rb +32 -12
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +2 -1
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +3 -2
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +2 -1
- data/lib/rails_ai_context/serializers/opencode_serializer.rb +6 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +159 -64
- data/lib/rails_ai_context/server.rb +5 -1
- data/lib/rails_ai_context/tools/diagnose.rb +309 -0
- data/lib/rails_ai_context/tools/generate_test.rb +519 -0
- data/lib/rails_ai_context/tools/get_context.rb +3 -3
- data/lib/rails_ai_context/tools/onboard.rb +453 -0
- data/lib/rails_ai_context/tools/query.rb +13 -1
- data/lib/rails_ai_context/tools/review_changes.rb +290 -0
- data/lib/rails_ai_context/tools/search_code.rb +6 -3
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +8 -4
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module RailsAiContext
|
|
6
|
+
module Tools
|
|
7
|
+
class ReviewChanges < BaseTool
|
|
8
|
+
tool_name "rails_review_changes"
|
|
9
|
+
description "PR/commit review context: shows changed files with relevant schema/model/route context per file, " \
|
|
10
|
+
"detects warnings (missing indexes, removed validations, changed associations, new routes without tests). " \
|
|
11
|
+
"Use when: reviewing changes before merging, understanding what a commit changed and its impact. " \
|
|
12
|
+
"Key params: ref (default 'HEAD' for uncommitted, or 'main', 'HEAD~3', commit SHA)."
|
|
13
|
+
|
|
14
|
+
input_schema(
|
|
15
|
+
properties: {
|
|
16
|
+
ref: {
|
|
17
|
+
type: "string",
|
|
18
|
+
description: "Git ref to diff against. 'HEAD' = uncommitted changes (default). 'main' = diff from main. 'HEAD~3' = last 3 commits. 'abc123' = specific commit."
|
|
19
|
+
},
|
|
20
|
+
files: {
|
|
21
|
+
type: "array",
|
|
22
|
+
items: { type: "string" },
|
|
23
|
+
description: "Filter to specific files (relative to Rails root). Omit to review all changed files."
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: false, open_world_hint: true)
|
|
29
|
+
|
|
30
|
+
MAX_DIFF_LINES_PER_FILE = 60
|
|
31
|
+
|
|
32
|
+
def self.call(ref: "HEAD", files: nil, server_context: nil)
|
|
33
|
+
root = Rails.root.to_s
|
|
34
|
+
|
|
35
|
+
# Verify git is available
|
|
36
|
+
_, status = Open3.capture2("git", "rev-parse", "--git-dir", chdir: root)
|
|
37
|
+
unless status.success?
|
|
38
|
+
return text_response("Not a git repository. `rails_review_changes` requires git.")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
changed = get_changed_files(ref, root)
|
|
42
|
+
changed = changed.select { |f| files.any? { |filter| f.include?(filter) } } if files&.any?
|
|
43
|
+
|
|
44
|
+
if changed.empty?
|
|
45
|
+
return text_response("No changes found for ref '#{ref}'.#{files ? " Filter: #{files.join(', ')}" : ""}")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Classify files
|
|
49
|
+
classified = changed.map { |f| { file: f, type: classify_file(f) } }
|
|
50
|
+
|
|
51
|
+
# Get commit log
|
|
52
|
+
commits = get_commit_log(ref, root)
|
|
53
|
+
|
|
54
|
+
# Build output
|
|
55
|
+
lines = [ "# Review: #{ref}", "" ]
|
|
56
|
+
|
|
57
|
+
# Summary
|
|
58
|
+
type_counts = classified.group_by { |c| c[:type] }.transform_values(&:size)
|
|
59
|
+
summary_parts = type_counts.map { |type, count| "#{count} #{type}" }
|
|
60
|
+
lines << "**#{changed.size} files changed** (#{summary_parts.join(', ')})"
|
|
61
|
+
lines << ""
|
|
62
|
+
|
|
63
|
+
if commits
|
|
64
|
+
lines << "## Commits"
|
|
65
|
+
lines << "```"
|
|
66
|
+
lines << commits
|
|
67
|
+
lines << "```"
|
|
68
|
+
lines << ""
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Detect warnings
|
|
72
|
+
warnings = detect_warnings(classified, root, ref)
|
|
73
|
+
if warnings.any?
|
|
74
|
+
lines << "## Warnings"
|
|
75
|
+
warnings.each { |w| lines << "- #{w}" }
|
|
76
|
+
lines << ""
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# File-by-file context
|
|
80
|
+
lines << "## File-by-File Context"
|
|
81
|
+
lines << ""
|
|
82
|
+
|
|
83
|
+
classified.each do |entry|
|
|
84
|
+
file_lines = gather_file_context(entry[:file], entry[:type], root, ref)
|
|
85
|
+
lines.concat(file_lines)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Next steps
|
|
89
|
+
rb_files = classified.select { |c| c[:file].end_with?(".rb") }.map { |c| c[:file] }
|
|
90
|
+
if rb_files.any?
|
|
91
|
+
file_list = rb_files.first(10).map { |f| "\"#{f}\"" }.join(", ")
|
|
92
|
+
lines << "_Next: `rails_validate(files:[#{file_list}], level:\"rails\")` to validate all changes._"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
text_response(lines.join("\n"))
|
|
96
|
+
rescue => e
|
|
97
|
+
text_response("Review error: #{e.message}")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
class << self
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def get_changed_files(ref, root)
|
|
104
|
+
if ref == "HEAD"
|
|
105
|
+
staged, _ = Open3.capture2("git", "diff", "--cached", "--name-only", chdir: root)
|
|
106
|
+
unstaged, _ = Open3.capture2("git", "diff", "--name-only", chdir: root)
|
|
107
|
+
untracked, _ = Open3.capture2("git", "ls-files", "--others", "--exclude-standard", chdir: root)
|
|
108
|
+
(staged.lines + unstaged.lines + untracked.lines).map(&:strip).reject(&:empty?).uniq
|
|
109
|
+
else
|
|
110
|
+
# Try three-dot (since divergence from ref)
|
|
111
|
+
output, status = Open3.capture2("git", "diff", "--name-only", "#{ref}...HEAD", chdir: root)
|
|
112
|
+
unless status.success?
|
|
113
|
+
# Fall back to two-dot
|
|
114
|
+
output, status = Open3.capture2("git", "diff", "--name-only", "#{ref}..HEAD", chdir: root)
|
|
115
|
+
unless status.success?
|
|
116
|
+
# Fall back to single ref diff
|
|
117
|
+
output, _ = Open3.capture2("git", "diff", "--name-only", ref, chdir: root)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
output.lines.map(&:strip).reject(&:empty?).uniq
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def get_commit_log(ref, root)
|
|
125
|
+
return nil if ref == "HEAD"
|
|
126
|
+
output, status = Open3.capture2("git", "log", "--oneline", "-10", "#{ref}..HEAD", chdir: root)
|
|
127
|
+
return nil unless status.success? && !output.strip.empty?
|
|
128
|
+
output.strip
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def classify_file(path)
|
|
132
|
+
case path
|
|
133
|
+
when %r{\Aapp/models/} then :model
|
|
134
|
+
when %r{\Aapp/controllers/} then :controller
|
|
135
|
+
when %r{\Adb/migrate/} then :migration
|
|
136
|
+
when %r{\Aapp/views/} then :view
|
|
137
|
+
when %r{\Aconfig/routes} then :routes
|
|
138
|
+
when %r{\A(spec|test)/} then :test
|
|
139
|
+
when %r{\Aapp/services/} then :service
|
|
140
|
+
when %r{\Aapp/jobs/} then :job
|
|
141
|
+
when %r{\Aapp/javascript/} then :javascript
|
|
142
|
+
when %r{\Aconfig/} then :config
|
|
143
|
+
else :other
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def gather_file_context(file, type, root, ref) # rubocop:disable Metrics
|
|
148
|
+
lines = [ "### #{file} (#{type})", "" ]
|
|
149
|
+
|
|
150
|
+
# Show diff summary
|
|
151
|
+
diff = get_file_diff(file, root, ref)
|
|
152
|
+
if diff
|
|
153
|
+
diff_lines = diff.lines
|
|
154
|
+
added = diff_lines.count { |l| l.start_with?("+") && !l.start_with?("+++") }
|
|
155
|
+
removed = diff_lines.count { |l| l.start_with?("-") && !l.start_with?("---") }
|
|
156
|
+
lines << "+#{added} / -#{removed} lines"
|
|
157
|
+
|
|
158
|
+
# Show truncated diff
|
|
159
|
+
content_lines = diff_lines.reject { |l| l.start_with?("diff ", "index ", "--- ", "+++ ") }
|
|
160
|
+
if content_lines.size > MAX_DIFF_LINES_PER_FILE
|
|
161
|
+
lines << "```diff"
|
|
162
|
+
lines.concat(content_lines.first(MAX_DIFF_LINES_PER_FILE).map(&:rstrip))
|
|
163
|
+
lines << "# ... #{content_lines.size - MAX_DIFF_LINES_PER_FILE} more lines"
|
|
164
|
+
lines << "```"
|
|
165
|
+
elsif content_lines.any?
|
|
166
|
+
lines << "```diff"
|
|
167
|
+
lines.concat(content_lines.map(&:rstrip))
|
|
168
|
+
lines << "```"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Pull relevant context per file type
|
|
173
|
+
case type
|
|
174
|
+
when :model
|
|
175
|
+
model_name = File.basename(file, ".rb").camelize
|
|
176
|
+
begin
|
|
177
|
+
result = GetModelDetails.call(model: model_name, detail: "standard")
|
|
178
|
+
text = result.content.first[:text]
|
|
179
|
+
lines << "" << "**Model context:** #{model_name}" unless text.include?("not found")
|
|
180
|
+
rescue; end
|
|
181
|
+
|
|
182
|
+
when :controller
|
|
183
|
+
ctrl_name = File.basename(file, ".rb").camelize
|
|
184
|
+
snake = ctrl_name.underscore.delete_suffix("_controller")
|
|
185
|
+
begin
|
|
186
|
+
result = GetRoutes.call(controller: snake, detail: "summary")
|
|
187
|
+
text = result.content.first[:text]
|
|
188
|
+
lines << "" << "**Routes:**" << text unless text.include?("not found") || text.include?("No routes")
|
|
189
|
+
rescue; end
|
|
190
|
+
|
|
191
|
+
when :migration
|
|
192
|
+
# Parse migration for table/column info
|
|
193
|
+
full_path = File.join(root, file)
|
|
194
|
+
if File.exist?(full_path)
|
|
195
|
+
source = File.read(full_path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue nil
|
|
196
|
+
if source
|
|
197
|
+
tables = source.scan(/(?:create_table|add_column|remove_column|rename_column|add_index|add_reference)\s+:(\w+)/).flatten.uniq
|
|
198
|
+
if tables.any?
|
|
199
|
+
lines << "" << "**Affects tables:** #{tables.join(', ')}"
|
|
200
|
+
tables.first(2).each do |t|
|
|
201
|
+
begin
|
|
202
|
+
result = GetSchema.call(table: t, detail: "summary")
|
|
203
|
+
text = result.content.first[:text]
|
|
204
|
+
lines << " #{t}: #{text.lines.first&.strip}" unless text.include?("not found")
|
|
205
|
+
rescue; end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
when :routes
|
|
212
|
+
begin
|
|
213
|
+
result = GetRoutes.call(detail: "summary")
|
|
214
|
+
lines << "" << "**Current routes:** #{result.content.first[:text].lines.first&.strip}"
|
|
215
|
+
rescue; end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
lines << ""
|
|
219
|
+
lines
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def get_file_diff(file, root, ref)
|
|
223
|
+
if ref == "HEAD"
|
|
224
|
+
output, status = Open3.capture2("git", "diff", "--", file, chdir: root)
|
|
225
|
+
if !status.success? || output.strip.empty?
|
|
226
|
+
output, status = Open3.capture2("git", "diff", "--cached", "--", file, chdir: root)
|
|
227
|
+
end
|
|
228
|
+
else
|
|
229
|
+
output, status = Open3.capture2("git", "diff", ref, "--", file, chdir: root)
|
|
230
|
+
end
|
|
231
|
+
status.success? && !output.strip.empty? ? output : nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def detect_warnings(classified, root, ref) # rubocop:disable Metrics
|
|
235
|
+
warnings = []
|
|
236
|
+
|
|
237
|
+
migration_files = classified.select { |c| c[:type] == :migration }
|
|
238
|
+
model_files = classified.select { |c| c[:type] == :model }
|
|
239
|
+
test_files = classified.select { |c| c[:type] == :test }
|
|
240
|
+
controller_files = classified.select { |c| c[:type] == :controller }
|
|
241
|
+
|
|
242
|
+
# Check migrations for missing indexes on foreign key columns
|
|
243
|
+
migration_files.each do |entry|
|
|
244
|
+
full_path = File.join(root, entry[:file])
|
|
245
|
+
next unless File.exist?(full_path)
|
|
246
|
+
source = File.read(full_path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
|
|
247
|
+
|
|
248
|
+
# New columns ending in _id without add_index
|
|
249
|
+
source.scan(/add_column\s+:\w+,\s+:(\w+_id)/).flatten.each do |col|
|
|
250
|
+
unless source.include?("add_index") && source.include?(col)
|
|
251
|
+
warnings << "**Missing index**: `#{entry[:file]}` adds `#{col}` without an index"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# add_reference without index: false check
|
|
256
|
+
source.scan(/add_reference\s+:(\w+),\s+:(\w+)/).each do |_table, ref_name|
|
|
257
|
+
if source.include?("index: false")
|
|
258
|
+
warnings << "**Disabled index**: `#{entry[:file]}` adds reference `#{ref_name}` with `index: false`"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Check model diffs for removed validations
|
|
264
|
+
model_files.each do |entry|
|
|
265
|
+
diff = get_file_diff(entry[:file], root, ref)
|
|
266
|
+
next unless diff
|
|
267
|
+
removed_validations = diff.lines.select { |l| l.start_with?("-") && l.match?(/validates?\s/) }
|
|
268
|
+
removed_validations.each do |line|
|
|
269
|
+
warnings << "**Removed validation**: `#{entry[:file]}` — `#{line.strip[1..].strip}`"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Check for controller changes without test changes
|
|
274
|
+
changed_ctrls = controller_files.map { |c| File.basename(c[:file], ".rb") }
|
|
275
|
+
changed_tests = test_files.map { |t| File.basename(t[:file], ".rb") }
|
|
276
|
+
changed_ctrls.each do |ctrl|
|
|
277
|
+
test_name = ctrl.sub("_controller", "_controller_test")
|
|
278
|
+
spec_name = ctrl.sub("_controller", "_controller_spec")
|
|
279
|
+
request_name = ctrl.sub("_controller", "_spec")
|
|
280
|
+
unless changed_tests.any? { |t| t == test_name || t == spec_name || t == request_name || t.include?(ctrl.delete_suffix("_controller")) }
|
|
281
|
+
warnings << "**No test changes**: `#{ctrl}.rb` was modified but no corresponding test file was changed"
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
warnings
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -180,7 +180,8 @@ module RailsAiContext
|
|
|
180
180
|
end
|
|
181
181
|
|
|
182
182
|
private_class_method def self.ripgrep_available?
|
|
183
|
-
@rg_available
|
|
183
|
+
return @rg_available unless @rg_available.nil?
|
|
184
|
+
@rg_available = system("which rg > /dev/null 2>&1")
|
|
184
185
|
end
|
|
185
186
|
|
|
186
187
|
private_class_method def self.search_with_ripgrep(pattern, search_path, file_type, max_results, root, ctx_lines = 0, exclude_tests: false)
|
|
@@ -462,8 +463,10 @@ module RailsAiContext
|
|
|
462
463
|
private_class_method def self.extract_controller_actions_from_matches(matches)
|
|
463
464
|
actions = []
|
|
464
465
|
matches.each do |m|
|
|
465
|
-
#
|
|
466
|
-
|
|
466
|
+
# Match standard RESTful action names from the content
|
|
467
|
+
if (match = m[:content].match(/\b(index|show|new|create|edit|update|destroy)\b/))
|
|
468
|
+
actions << match[1]
|
|
469
|
+
end
|
|
467
470
|
end
|
|
468
471
|
actions.uniq.first(3)
|
|
469
472
|
end
|
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: 4.
|
|
4
|
+
version: 4.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- crisnahine
|
|
@@ -170,7 +170,7 @@ dependencies:
|
|
|
170
170
|
description: |
|
|
171
171
|
rails-ai-context gives AI coding agents a complete mental model of your Rails
|
|
172
172
|
app — not just files, but how schema, models, routes, controllers, views, and
|
|
173
|
-
conventions connect.
|
|
173
|
+
conventions connect. 37 live tools (via MCP server or CLI) let agents query
|
|
174
174
|
structure on demand with semantic validation that catches cross-file errors
|
|
175
175
|
(wrong columns, missing partials, broken routes) before code runs.
|
|
176
176
|
Auto-generates context files for Claude Code, Cursor, GitHub Copilot, and
|
|
@@ -262,6 +262,8 @@ files:
|
|
|
262
262
|
- lib/rails_ai_context/tools/analyze_feature.rb
|
|
263
263
|
- lib/rails_ai_context/tools/base_tool.rb
|
|
264
264
|
- lib/rails_ai_context/tools/dependency_graph.rb
|
|
265
|
+
- lib/rails_ai_context/tools/diagnose.rb
|
|
266
|
+
- lib/rails_ai_context/tools/generate_test.rb
|
|
265
267
|
- lib/rails_ai_context/tools/get_callbacks.rb
|
|
266
268
|
- lib/rails_ai_context/tools/get_component_catalog.rb
|
|
267
269
|
- lib/rails_ai_context/tools/get_concern.rb
|
|
@@ -286,9 +288,11 @@ files:
|
|
|
286
288
|
- lib/rails_ai_context/tools/get_turbo_map.rb
|
|
287
289
|
- lib/rails_ai_context/tools/get_view.rb
|
|
288
290
|
- lib/rails_ai_context/tools/migration_advisor.rb
|
|
291
|
+
- lib/rails_ai_context/tools/onboard.rb
|
|
289
292
|
- lib/rails_ai_context/tools/performance_check.rb
|
|
290
293
|
- lib/rails_ai_context/tools/query.rb
|
|
291
294
|
- lib/rails_ai_context/tools/read_logs.rb
|
|
295
|
+
- lib/rails_ai_context/tools/review_changes.rb
|
|
292
296
|
- lib/rails_ai_context/tools/search_code.rb
|
|
293
297
|
- lib/rails_ai_context/tools/search_docs.rb
|
|
294
298
|
- lib/rails_ai_context/tools/security_scan.rb
|
|
@@ -311,7 +315,7 @@ post_install_message: |
|
|
|
311
315
|
rails-ai-context installed! Quick start:
|
|
312
316
|
rails generate rails_ai_context:install
|
|
313
317
|
rails ai:context # generate context files
|
|
314
|
-
rails 'ai:tool[schema]' # run any of the
|
|
318
|
+
rails 'ai:tool[schema]' # run any of the 37 tools from CLI
|
|
315
319
|
rails ai:serve # start MCP server (optional)
|
|
316
320
|
rdoc_options: []
|
|
317
321
|
require_paths:
|
|
@@ -329,6 +333,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
329
333
|
requirements: []
|
|
330
334
|
rubygems_version: 3.6.9
|
|
331
335
|
specification_version: 4
|
|
332
|
-
summary: Give AI agents a complete mental model of your Rails app —
|
|
336
|
+
summary: Give AI agents a complete mental model of your Rails app — 37 tools via MCP
|
|
333
337
|
or CLI. Zero config.
|
|
334
338
|
test_files: []
|