rails-ai-context 3.0.1 → 3.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6eefc7e3df780d0a0553d8d33945cba9f799ff12e0013f2f4e4d8dffe2243a08
4
- data.tar.gz: fed18a63451cf2a649288c5e4f8a5a13386d98eb164cc312b2e559ebc788b739
3
+ metadata.gz: a6bdb0dcbde3c7abfab69bee14e661afc8a260fe3d00faa99ecc4becbc58a19b
4
+ data.tar.gz: deacfa10c6c188d0482fc82199f085b4f13cbf24b0cf06e0977074ecf67864d8
5
5
  SHA512:
6
- metadata.gz: 84fc5cb59112194dbc3a6f31c1473734646f7cd5cb29777f58290a2cc12c1ebf87036186ed7cc4fd60b52365b9486adea6a9995a561ee4f00c3348487deec532
7
- data.tar.gz: b74d3406587a18690aa4bffcfbd928efd4b0ead69c176e14163821dee96b3d983958c007cf0c74073b94875e098865ab73cf239b67a59a0565b4911d7d9a7cfe
6
+ metadata.gz: bf049844c52cd8b88371a352907b08c9863668424ae37f9393d72c7210501c21fcc2ea3df797e59184b603b98e723f0bd07ebb4e36503852344c27d96970a308
7
+ data.tar.gz: ab5cf1f7f4031d605db8f076da5544ede9c1aa48d426dc57482a45551d46741c687caaaf0e8722932d8d982c6a9af5c86f425e117a80bcb88b36d2d1d761040f
data/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ 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
+ ## [3.1.0] - 2026-03-26
9
+
10
+ ### Fixed
11
+
12
+ - **Consistent input normalization across all tools** — AI agents and humans can now use any casing or format and tools resolve correctly:
13
+ - `model=brand_profile` (snake_case) now resolves to `BrandProfile` via `.underscore` comparison in `get_model_details`.
14
+ - `table=Cook` (model name) now resolves to `cooks` table via `.underscore.pluralize` normalization in `get_schema`.
15
+ - `controller=CooksController` now works in `get_view` and `get_routes` — both strip `Controller`/`_controller` suffix consistently, matching `get_controllers` behavior.
16
+ - `controller=cooks_controller` no longer leaves a trailing underscore in route matching.
17
+ - `stimulus=CookStatus` (PascalCase) now resolves to `cook_status` via `.underscore` conversion in `get_stimulus`.
18
+ - `partial=_status_badge` (underscore-prefixed, no directory) now searches recursively across all view directories in `get_partial_interface`.
19
+ - `model=cooks` (plural) now tries `.singularize` for test file lookup in `get_test_info`.
20
+ - **Smarter fuzzy matching** — `BaseTool.find_closest_match` now prefers shortest substring match (so `Cook` suggests `cooks`, not `cook_comments`) and supports underscore/classify variant matching.
21
+ - **File path suggestions in validate** — `files=["cook.rb"]` now suggests `app/models/cook.rb` when the file isn't found at the given path.
22
+ - **Empty parameter validation** — `edit_context` now returns friendly messages for empty `file` or `near` parameters instead of hard errors.
23
+
8
24
  ## [3.0.0] - 2026-03-26
9
25
 
10
26
  ### Removed
data/CLAUDE.md CHANGED
@@ -51,7 +51,7 @@ structure to AI assistants via the Model Context Protocol (MCP).
51
51
  ## Testing
52
52
 
53
53
  ```bash
54
- bundle exec rspec # Run specs (653 examples)
54
+ bundle exec rspec # Run specs (681 examples)
55
55
  bundle exec rubocop # Lint
56
56
  ```
57
57
 
data/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  **Works with:** Claude Code • Cursor • GitHub Copilot • OpenCode • Any terminal
11
11
 
12
- > Built by a Rails developer with 10+ years of production experience. AI assisted — the same way it assists me shipping features at work. I designed the architecture, made every decision, reviewed every line, and wrote 653 tests. This gem exists because I understand Rails deeply enough to know exactly what AI agents get wrong and what context they need to get it right.
12
+ > Built by a Rails developer with 10+ years of production experience. AI assisted — the same way it assists me shipping features at work. I designed the architecture, made every decision, reviewed every line, and wrote 681 tests. This gem exists because I understand Rails deeply enough to know exactly what AI agents get wrong and what context they need to get it right.
13
13
 
14
14
  ```bash
15
15
  gem "rails-ai-context", group: :development
@@ -340,7 +340,7 @@ end
340
340
  ```bash
341
341
  git clone https://github.com/crisnahine/rails-ai-context.git
342
342
  cd rails-ai-context && bundle install
343
- bundle exec rspec # 653 examples
343
+ bundle exec rspec # 681 examples
344
344
  bundle exec rubocop # Lint
345
345
  ```
346
346
 
@@ -219,27 +219,37 @@ module RailsAiContext
219
219
  end
220
220
 
221
221
  # Validate kwargs against the tool's input_schema.
222
+ # For missing required params: strip empty values so the tool's own guards
223
+ # can return a friendly response (matching MCP behavior).
224
+ # For invalid enums: strip the bad value and let the tool use its default.
222
225
  def validate_kwargs!(kwargs, schema)
223
226
  properties = schema[:properties] || {}
224
227
  required = (schema[:required] || []).map(&:to_s)
225
228
 
226
- # Check required params
229
+ # For required params with empty-string values, keep the key but set to nil
230
+ # so the tool's own guards can return friendly "parameter is required" messages
231
+ # (matching MCP behavior). We keep the key to avoid Ruby ArgumentError on
232
+ # required keyword arguments.
227
233
  required.each do |param|
228
- unless kwargs.key?(param.to_sym) && !kwargs[param.to_sym].nil? && kwargs[param.to_sym].to_s != ""
229
- raise InvalidArgumentError,
230
- "Missing required parameter '#{param}' for #{tool_class.tool_name}.\n" \
231
- "Run: rails-ai-context tool #{self.class.short_name(tool_class.tool_name)} --help"
234
+ if kwargs.key?(param.to_sym) && kwargs[param.to_sym].to_s.strip == ""
235
+ kwargs[param.to_sym] = nil
236
+ elsif !kwargs.key?(param.to_sym)
237
+ kwargs[param.to_sym] = nil
232
238
  end
233
239
  end
234
240
 
235
- # Check enum constraints
241
+ # Check enum constraints — downcase before comparing for case-insensitive match.
242
+ # If invalid, strip the param so the tool uses its default behavior.
236
243
  kwargs.each do |key, value|
237
244
  prop = properties[key]
238
245
  next unless prop&.dig(:enum)
239
- unless prop[:enum].include?(value.to_s)
240
- raise InvalidArgumentError,
241
- "Invalid value '#{value}' for --#{key.to_s.tr('_', '-')}. " \
242
- "Must be one of: #{prop[:enum].join(', ')}"
246
+
247
+ # Try case-insensitive match first
248
+ matched = prop[:enum].find { |e| e.to_s.downcase == value.to_s.downcase }
249
+ if matched
250
+ kwargs[key] = matched
251
+ else
252
+ kwargs.delete(key)
243
253
  end
244
254
  end
245
255
  end
@@ -65,13 +65,24 @@ module RailsAiContext
65
65
  text_response(lines.join("\n"))
66
66
  end
67
67
 
68
- # Simple fuzzy match: find the closest available name by substring or edit distance
68
+ # Fuzzy match: find the closest available name by exact, underscore, substring, or prefix
69
69
  def find_closest_match(input, available)
70
70
  return nil if available.empty?
71
71
  downcased = input.downcase
72
- # Exact substring match first
73
- exact = available.find { |a| a.downcase.include?(downcased) || downcased.include?(a.downcase) }
72
+ underscored = input.underscore.downcase
73
+
74
+ # Exact case-insensitive match (including underscore/classify variants)
75
+ exact = available.find do |a|
76
+ a_down = a.downcase
77
+ a_under = a.underscore.downcase
78
+ a_down == downcased || a_under == underscored || a_down == underscored || a_under == downcased
79
+ end
74
80
  return exact if exact
81
+
82
+ # Substring match — prefer shortest (most specific) to avoid cook → cook_comments
83
+ substring_matches = available.select { |a| a.downcase.include?(downcased) || downcased.include?(a.downcase) }
84
+ return substring_matches.min_by(&:length) if substring_matches.any?
85
+
75
86
  # Prefix match
76
87
  available.find { |a| a.downcase.start_with?(downcased[0..2]) }
77
88
  end
@@ -36,7 +36,11 @@ module RailsAiContext
36
36
  SENSITIVE_PATTERNS = nil # uses configuration.sensitive_patterns
37
37
 
38
38
  def self.call(file:, near:, context_lines: 5, server_context: nil)
39
- # Reject empty search term
39
+ # Reject empty parameters
40
+ if file.nil? || file.strip.empty?
41
+ return text_response("The `file` parameter is required. Provide a path relative to Rails root (e.g. 'app/models/cook.rb').")
42
+ end
43
+
40
44
  if near.nil? || near.strip.empty?
41
45
  return text_response("The `near` parameter is required. Provide a method name, keyword, or string to find.")
42
46
  end
@@ -40,7 +40,8 @@ module RailsAiContext
40
40
  # Specific model — always full detail (strip whitespace for fuzzy input)
41
41
  if model
42
42
  model = model.strip
43
- key = models.keys.find { |k| k.downcase == model.downcase } || model
43
+ model_under = model.underscore
44
+ key = models.keys.find { |k| k.downcase == model.downcase || k.underscore == model_under } || model
44
45
  data = models[key]
45
46
  unless data
46
47
  return not_found_response("Model", model, models.keys.sort,
@@ -201,6 +201,7 @@ module RailsAiContext
201
201
 
202
202
  # Resolve a partial reference to an actual file path on disk.
203
203
  # Handles both underscore-prefixed filenames and non-prefixed input.
204
+ # Falls back to recursive search when no directory is specified.
204
205
  private_class_method def self.resolve_partial_path(views_dir, partial)
205
206
  # Normalize: strip leading underscore from basename if provided
206
207
  parts = partial.split("/")
@@ -211,10 +212,11 @@ module RailsAiContext
211
212
  prefixed_basename = basename.start_with?("_") ? basename : "_#{basename}"
212
213
  unprefixed_basename = basename.delete_prefix("_")
213
214
 
215
+ extensions = %w[.html.erb .erb .html.haml .haml .html.slim .slim]
214
216
  candidates = []
215
217
 
216
218
  # Try prefixed name with various extensions
217
- %w[.html.erb .erb .html.haml .haml .html.slim .slim].each do |ext|
219
+ extensions.each do |ext|
218
220
  candidates << File.join(views_dir, *dir_parts, "#{prefixed_basename}#{ext}")
219
221
  candidates << File.join(views_dir, *dir_parts, "#{unprefixed_basename}#{ext}")
220
222
  end
@@ -223,6 +225,19 @@ module RailsAiContext
223
225
  candidates << File.join(views_dir, partial)
224
226
 
225
227
  found = candidates.find { |c| File.exist?(c) }
228
+
229
+ # Fallback: if no directory was specified and direct lookup failed,
230
+ # search recursively for the partial across all view directories
231
+ if found.nil? && dir_parts.empty?
232
+ extensions.each do |ext|
233
+ matches = Dir.glob(File.join(views_dir, "**", "#{prefixed_basename}#{ext}"))
234
+ if matches.any?
235
+ found = matches.first
236
+ break
237
+ end
238
+ end
239
+ end
240
+
226
241
  return nil unless found
227
242
 
228
243
  # Path traversal protection
@@ -53,10 +53,11 @@ module RailsAiContext
53
53
  by_controller = by_controller.reject { |k, _| route_prefixes.any? { |p| k.downcase.start_with?(p) } }
54
54
  end
55
55
 
56
- # Filter by controller — accepts both slash and :: notation
56
+ # Filter by controller — accepts "cooks", "CooksController", "cooks_controller", "Api::V1::Posts"
57
57
  if controller
58
- normalized = controller.downcase.tr("::", "/").delete_suffix("controller")
59
- filtered = by_controller.select { |k, _| k.downcase.include?(normalized) }
58
+ normalized = controller.underscore.delete_suffix("_controller")
59
+ normalized_alt = controller.downcase.delete_suffix("_controller").delete_suffix("controller")
60
+ filtered = by_controller.select { |k, _| k.downcase.include?(normalized) || k.downcase.include?(normalized_alt) }
60
61
  return text_response("No routes for '#{controller}'. Controllers: #{by_controller.keys.sort.join(', ')}") if filtered.empty?
61
62
  by_controller = filtered
62
63
  end
@@ -56,9 +56,14 @@ module RailsAiContext
56
56
  offset = [ offset.to_i, 0 ].max
57
57
  limit = [ limit.to_i, 0 ].max if limit && limit.to_i < 0
58
58
 
59
- # Single table — case-insensitive lookup
59
+ # Single table — case-insensitive lookup with model name normalization
60
+ # Accepts: "users", "Users", "User" (model name → pluralized+underscored table)
60
61
  if table
61
- table_key = tables.keys.find { |k| k.downcase == table.downcase } || table
62
+ table_down = table.downcase
63
+ table_as_table = table.underscore.pluralize # Cook → cooks, BrandProfile → brand_profiles
64
+ table_key = tables.keys.find { |k|
65
+ k.downcase == table_down || k == table_as_table || k == table.underscore
66
+ } || table
62
67
  table_data = tables[table_key]
63
68
  unless table_data
64
69
  return not_found_response("Table", table, tables.keys.sort,
@@ -44,7 +44,12 @@ module RailsAiContext
44
44
  # (HTML uses data-controller="weekly-chart", file is weekly_chart_controller.js)
45
45
  if controller
46
46
  normalized = controller.downcase.tr("-", "_")
47
- ctrl = all_controllers.find { |c| c[:name]&.downcase&.tr("-", "_") == normalized }
47
+ # Also handle PascalCase: CookStatus cook_status
48
+ underscored = controller.underscore.downcase.tr("-", "_")
49
+ ctrl = all_controllers.find { |c|
50
+ name_norm = c[:name]&.downcase&.tr("-", "_")
51
+ name_norm == normalized || name_norm == underscored
52
+ }
48
53
  unless ctrl
49
54
  names = all_controllers.map { |c| c[:name] }.sort
50
55
  return not_found_response("Stimulus controller", controller, names,
@@ -157,16 +157,27 @@ module RailsAiContext
157
157
  end
158
158
 
159
159
  private_class_method def self.find_test_file(name, type, detail = "full")
160
- # Normalize: accept "Bonus::CrisesController", "bonus/crises", "Crises"
160
+ # Normalize: accept "Bonus::CrisesController", "bonus/crises", "Crises", "cooks" (plural)
161
161
  snake = name.to_s.tr("/", "::").underscore.sub(/_controller$/, "")
162
+ # For models, also try singular form (cooks → cook)
163
+ snake_singular = snake.singularize
162
164
  candidates = case type
163
165
  when :model
164
- [
166
+ base = [
165
167
  "spec/models/#{snake}_spec.rb",
166
168
  "test/models/#{snake}_test.rb",
167
169
  "spec/models/concerns/#{snake}_spec.rb",
168
170
  "test/models/concerns/#{snake}_test.rb"
169
171
  ]
172
+ if snake != snake_singular
173
+ base += [
174
+ "spec/models/#{snake_singular}_spec.rb",
175
+ "test/models/#{snake_singular}_test.rb",
176
+ "spec/models/concerns/#{snake_singular}_spec.rb",
177
+ "test/models/concerns/#{snake_singular}_test.rb"
178
+ ]
179
+ end
180
+ base
170
181
  when :controller
171
182
  [
172
183
  "spec/controllers/#{snake}_controller_spec.rb",
@@ -51,9 +51,17 @@ module RailsAiContext
51
51
  end
52
52
 
53
53
  if controller
54
- ctrl_lower = controller.downcase
55
- filtered_templates = templates.select { |k, _| k.downcase.start_with?(ctrl_lower + "/") }
56
- filtered_partials = partials.select { |k, _| k.downcase.start_with?(ctrl_lower + "/") }
54
+ # Normalize: accept "CooksController", "cooks", "cooks_controller", "Bonus::CooksController"
55
+ ctrl_lower = controller.underscore.delete_suffix("_controller")
56
+ ctrl_lower_alt = controller.downcase.delete_suffix("controller")
57
+ filtered_templates = templates.select { |k, _|
58
+ k_down = k.downcase
59
+ k_down.start_with?(ctrl_lower + "/") || k_down.start_with?(ctrl_lower_alt + "/")
60
+ }
61
+ filtered_partials = partials.select { |k, _|
62
+ k_down = k.downcase
63
+ k_down.start_with?(ctrl_lower + "/") || k_down.start_with?(ctrl_lower_alt + "/")
64
+ }
57
65
 
58
66
  if filtered_templates.empty? && filtered_partials.empty?
59
67
  all_dirs = (templates.keys + partials.keys).map { |k| k.split("/").first }.uniq.sort
@@ -303,7 +311,12 @@ module RailsAiContext
303
311
  .sort
304
312
 
305
313
  if controller
306
- templates = templates.select { |t| t.downcase.start_with?(controller.downcase + "/") }
314
+ ctrl_lower = controller.underscore.delete_suffix("_controller")
315
+ ctrl_lower_alt = controller.downcase.delete_suffix("controller")
316
+ templates = templates.select { |t|
317
+ t_down = t.downcase
318
+ t_down.start_with?(ctrl_lower + "/") || t_down.start_with?(ctrl_lower_alt + "/")
319
+ }
307
320
  end
308
321
 
309
322
  lines = [ "# Views (#{templates.size} templates)", "" ]
@@ -37,7 +37,7 @@ module RailsAiContext
37
37
  # ── Main entry point ─────────────────────────────────────────────
38
38
 
39
39
  def self.call(files:, level: "syntax", server_context: nil)
40
- return text_response("No files provided.") if files.empty?
40
+ return text_response("No files provided. Pass file paths relative to Rails root (e.g. files:[\"app/models/cook.rb\"]).") if files.nil? || files.empty?
41
41
  return text_response("Too many files (#{files.size}). Maximum is #{max_files} per call.") if files.size > max_files
42
42
 
43
43
  results = []
@@ -48,7 +48,9 @@ module RailsAiContext
48
48
  full_path = Rails.root.join(file)
49
49
 
50
50
  unless File.exist?(full_path)
51
- results << "\u2717 #{file} \u2014 file not found"
51
+ suggestion = find_file_suggestion(file)
52
+ hint = suggestion ? " Did you mean '#{suggestion}'?" : ""
53
+ results << "\u2717 #{file} \u2014 file not found.#{hint}"
52
54
  total += 1
53
55
  next
54
56
  end
@@ -122,6 +124,24 @@ module RailsAiContext
122
124
 
123
125
  # ── Ruby validation ──────────────────────────────────────────────
124
126
 
127
+ # Search common Rails directories for a file by basename and suggest the full path
128
+ private_class_method def self.find_file_suggestion(file)
129
+ basename = File.basename(file)
130
+ %w[app/models app/controllers app/views app/helpers app/jobs app/mailers
131
+ app/services app/channels lib config].each do |dir|
132
+ candidate = File.join(dir, basename)
133
+ return candidate if File.exist?(Rails.root.join(candidate))
134
+ end
135
+
136
+ # Broader recursive search
137
+ matches = Dir.glob(File.join(Rails.root, "app", "**", basename)).first(1)
138
+ return matches.first.sub("#{Rails.root}/", "") if matches.any?
139
+
140
+ nil
141
+ rescue
142
+ nil
143
+ end
144
+
125
145
  private_class_method def self.validate_ruby(full_path)
126
146
  prism_available? ? validate_ruby_prism(full_path) : validate_ruby_subprocess(full_path)
127
147
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "3.0.1"
4
+ VERSION = "3.1.0"
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": "3.0.1",
10
+ "version": "3.1.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "mcpb",
14
- "identifier": "https://github.com/crisnahine/rails-ai-context/releases/download/v3.0.1/rails-ai-context-mcp.mcpb",
14
+ "identifier": "https://github.com/crisnahine/rails-ai-context/releases/download/v3.1.0/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: 3.0.1
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine