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.
@@ -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 ||= system("which rg > /dev/null 2>&1")
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
- # Look for the method name from indentation context
466
- actions << $1 if m[:content].match?(/\b(create|index|show|new|edit|update|destroy|[a-z_]+)\b/)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "4.2.2"
4
+ VERSION = "4.3.0"
5
5
  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.2.2
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. 33 live tools (via MCP server or CLI) let agents query
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 33 tools from CLI
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 — 33 tools via MCP
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: []