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 +4 -4
- data/CHANGELOG.md +16 -0
- data/CLAUDE.md +1 -1
- data/README.md +2 -2
- data/lib/rails_ai_context/cli/tool_runner.rb +20 -10
- data/lib/rails_ai_context/tools/base_tool.rb +14 -3
- data/lib/rails_ai_context/tools/get_edit_context.rb +5 -1
- data/lib/rails_ai_context/tools/get_model_details.rb +2 -1
- data/lib/rails_ai_context/tools/get_partial_interface.rb +16 -1
- data/lib/rails_ai_context/tools/get_routes.rb +4 -3
- data/lib/rails_ai_context/tools/get_schema.rb +7 -2
- data/lib/rails_ai_context/tools/get_stimulus.rb +6 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +13 -2
- data/lib/rails_ai_context/tools/get_view.rb +17 -4
- data/lib/rails_ai_context/tools/validate.rb +22 -2
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +2 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a6bdb0dcbde3c7abfab69bee14e661afc8a260fe3d00faa99ecc4becbc58a19b
|
|
4
|
+
data.tar.gz: deacfa10c6c188d0482fc82199f085b4f13cbf24b0cf06e0977074ecf67864d8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
|
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 #
|
|
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
|
-
#
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
56
|
+
# Filter by controller — accepts "cooks", "CooksController", "cooks_controller", "Api::V1::Posts"
|
|
57
57
|
if controller
|
|
58
|
-
normalized = controller.
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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
|
|
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
|
|
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"
|