rails-ai-context 1.2.0 → 1.2.1
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 +14 -0
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +11 -0
- data/lib/rails_ai_context/introspectors/model_introspector.rb +119 -10
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +18 -2
- data/lib/rails_ai_context/tools/analyze_feature.rb +7 -1
- data/lib/rails_ai_context/tools/get_model_details.rb +17 -5
- data/lib/rails_ai_context/tools/get_schema.rb +5 -1
- data/lib/rails_ai_context/version.rb +1 -1
- 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: 5d0ee652e69687c50687cd1b13209688aa5eecc24cf892fa807f9a3d03c7f2de
|
|
4
|
+
data.tar.gz: e689153ebb668ead61eb375c551a4724c73ce9338ae78a48b6acae63b9a63266
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 415542e44483875ce828fb3476d7d76a84d3df8a3ad3b4a8e512b649563e74bbc570244c5669d16fad7af574d2eb60c8e07fc743dc0126cfcdb4d728fd5b55f5
|
|
7
|
+
data.tar.gz: e8bc26c2edf09e86eee60b70adeee830b4ed9df130a5da5b658f721077d50cb21d496e677fae0dec7059d7416192b9c0ed27727f786ef76a2522aa63be16a025
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ 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
|
+
## [1.2.1] - 2026-03-23
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **New models now discovered via filesystem fallback** — when `ActiveRecord::Base.descendants` misses a newly created model, the introspector scans `app/models/*.rb` and constantizes them. Fixes model invisibility until MCP restart.
|
|
13
|
+
- **Devise meta-methods no longer fill class/instance method caps** — filtered 40+ Devise-generated methods (authentication_keys=, email_regexp=, password_required?, etc.). Source-defined methods now prioritized over reflection-discovered ones.
|
|
14
|
+
- **Controller `unless:`/`if:` conditions now extracted** — filters like `before_action :authenticate_user!, unless: :devise_controller?` now show the condition. Previously silently dropped.
|
|
15
|
+
- **Empty string defaults shown as `""`** — schema tool now renders `""` instead of a blank cell for empty string defaults. AI can distinguish "no default" from "empty string default".
|
|
16
|
+
- **Implicit belongs_to validations labeled** — `presence on user` from `belongs_to :user` now shows `_(implicit from belongs_to)_` and filters phantom `(message: required)` options.
|
|
17
|
+
- **Array columns shown as `type[]`** in generated rules — `string` columns with `array: true` now render as `string[]` in schema rules.
|
|
18
|
+
- **External ID columns no longer hidden** — columns like `paymongo_checkout_id` and `stripe_payment_id` are now shown in schema rules. Only conventional Rails FK columns (matching a table name) are filtered.
|
|
19
|
+
- **Column defaults shown in generated rules** — columns with non-nil defaults now show `(=value)` inline.
|
|
20
|
+
- **`analyze_feature` matches models by table name and underscore form** — `feature:"share"` now finds `CookShare` (via `cook_shares` table and `cook_share` underscore form), not just exact model name substring.
|
|
21
|
+
|
|
8
22
|
## [1.2.0] - 2026-03-23
|
|
9
23
|
|
|
10
24
|
### Added
|
|
@@ -167,6 +167,8 @@ module RailsAiContext
|
|
|
167
167
|
if (sc = source_constraints[f[:name]])
|
|
168
168
|
f[:only] = sc[:only] if sc[:only]&.any?
|
|
169
169
|
f[:except] = sc[:except] if sc[:except]&.any?
|
|
170
|
+
f[:unless] = sc[:unless] if sc[:unless]
|
|
171
|
+
f[:if] = sc[:if] if sc[:if]
|
|
170
172
|
end
|
|
171
173
|
end
|
|
172
174
|
return reflection_filters
|
|
@@ -221,6 +223,15 @@ module RailsAiContext
|
|
|
221
223
|
except = parse_action_constraint(line, "except")
|
|
222
224
|
filter[:only] = only if only&.any?
|
|
223
225
|
filter[:except] = except if except&.any?
|
|
226
|
+
|
|
227
|
+
# Extract conditional modifiers (unless:, if:)
|
|
228
|
+
if (unless_match = line.match(/unless:\s*:(\w+[?!]?)/))
|
|
229
|
+
filter[:unless] = unless_match[1]
|
|
230
|
+
end
|
|
231
|
+
if (if_match = line.match(/\bif:\s*:(\w+[?!]?)/))
|
|
232
|
+
filter[:if] = if_match[1]
|
|
233
|
+
end
|
|
234
|
+
|
|
224
235
|
filters << filter
|
|
225
236
|
end
|
|
226
237
|
filters
|
|
@@ -47,11 +47,33 @@ module RailsAiContext
|
|
|
47
47
|
def discover_models
|
|
48
48
|
return [] unless defined?(ActiveRecord::Base)
|
|
49
49
|
|
|
50
|
-
ActiveRecord::Base.descendants.reject do |model|
|
|
50
|
+
models = ActiveRecord::Base.descendants.reject do |model|
|
|
51
51
|
model.abstract_class? ||
|
|
52
52
|
model.name.nil? ||
|
|
53
53
|
config.excluded_models.include?(model.name)
|
|
54
|
-
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Filesystem fallback — discover model files not yet loaded by descendants
|
|
57
|
+
models_dir = File.join(app.root.to_s, "app", "models")
|
|
58
|
+
if Dir.exist?(models_dir)
|
|
59
|
+
known = models.map(&:name).to_set
|
|
60
|
+
Dir.glob(File.join(models_dir, "**", "*.rb")).each do |path|
|
|
61
|
+
relative = path.sub("#{models_dir}/", "").sub(/\.rb\z/, "")
|
|
62
|
+
class_name = relative.camelize
|
|
63
|
+
next if known.include?(class_name)
|
|
64
|
+
next if config.excluded_models.include?(class_name)
|
|
65
|
+
|
|
66
|
+
begin
|
|
67
|
+
klass = class_name.constantize
|
|
68
|
+
next unless klass < ActiveRecord::Base && !klass.abstract_class?
|
|
69
|
+
models << klass
|
|
70
|
+
rescue NameError, LoadError
|
|
71
|
+
# Not a valid model class
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
models.uniq.sort_by(&:name)
|
|
55
77
|
end
|
|
56
78
|
|
|
57
79
|
def extract_model_details(model)
|
|
@@ -196,29 +218,116 @@ module RailsAiContext
|
|
|
196
218
|
RailsAiContext.configuration.excluded_concerns.any? { |pattern| name.match?(pattern) }
|
|
197
219
|
end
|
|
198
220
|
|
|
221
|
+
DEVISE_CLASS_METHOD_PATTERNS = %w[
|
|
222
|
+
authentication_keys= case_insensitive_keys= strip_whitespace_keys=
|
|
223
|
+
reset_password_keys= confirmation_keys= unlock_keys=
|
|
224
|
+
email_regexp= password_length= timeout_in= remember_for=
|
|
225
|
+
sign_in_after_reset_password= sign_in_after_change_password=
|
|
226
|
+
reconfirmable= extend_remember_period= pepper=
|
|
227
|
+
stretches= allow_unconfirmed_access_for=
|
|
228
|
+
confirm_within= remember_for= unlock_in=
|
|
229
|
+
lock_strategy= unlock_strategy= maximum_attempts=
|
|
230
|
+
paranoid= last_attempt_warning=
|
|
231
|
+
].to_set.freeze
|
|
232
|
+
|
|
199
233
|
def extract_public_class_methods(model)
|
|
200
234
|
scope_names = extract_scopes(model).map(&:to_s)
|
|
201
|
-
|
|
235
|
+
|
|
236
|
+
# Prioritize methods defined in the model's own source file
|
|
237
|
+
source_methods = extract_source_class_methods(model)
|
|
238
|
+
|
|
239
|
+
all_methods = (model.methods - ActiveRecord::Base.methods - Object.methods)
|
|
202
240
|
.reject { |m|
|
|
203
241
|
ms = m.to_s
|
|
204
|
-
ms.start_with?("_", "autosave") ||
|
|
242
|
+
ms.start_with?("_", "autosave") ||
|
|
243
|
+
scope_names.include?(ms) ||
|
|
244
|
+
DEVISE_CLASS_METHOD_PATTERNS.include?(ms) ||
|
|
245
|
+
ms.end_with?("=") && ms.length > 20 # Devise setter-like methods
|
|
205
246
|
}
|
|
206
|
-
.sort
|
|
207
|
-
.first(30) # Cap to avoid noise
|
|
208
247
|
.map(&:to_s)
|
|
248
|
+
.sort
|
|
249
|
+
|
|
250
|
+
# Source-defined methods first, then reflection-discovered ones
|
|
251
|
+
ordered = source_methods + (all_methods - source_methods)
|
|
252
|
+
ordered.first(30)
|
|
209
253
|
end
|
|
210
254
|
|
|
255
|
+
def extract_source_class_methods(model)
|
|
256
|
+
path = model_source_path(model)
|
|
257
|
+
return [] unless path && File.exist?(path)
|
|
258
|
+
|
|
259
|
+
source = File.read(path)
|
|
260
|
+
methods = []
|
|
261
|
+
in_class_methods = false
|
|
262
|
+
source.each_line do |line|
|
|
263
|
+
in_class_methods = true if line.match?(/\A\s*(?:class << self|def self\.)/)
|
|
264
|
+
if line.match?(/\A\s*def self\.(\w+)/)
|
|
265
|
+
methods << line.match(/def self\.(\w+)/)[1]
|
|
266
|
+
end
|
|
267
|
+
if in_class_methods && line.match?(/\A\s*def (\w+)/)
|
|
268
|
+
methods << line.match(/def (\w+)/)[1]
|
|
269
|
+
end
|
|
270
|
+
in_class_methods = false if in_class_methods && line.match?(/\A\s*end\s*$/) && !line.match?(/def/)
|
|
271
|
+
end
|
|
272
|
+
methods.uniq
|
|
273
|
+
rescue
|
|
274
|
+
[]
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
DEVISE_INSTANCE_PATTERNS = %w[
|
|
278
|
+
password_required? email_required? confirmation_required?
|
|
279
|
+
active_for_authentication? inactive_message authenticatable_salt
|
|
280
|
+
after_database_authentication send_devise_notification
|
|
281
|
+
send_confirmation_instructions send_reset_password_instructions
|
|
282
|
+
send_unlock_instructions send_on_create_confirmation_instructions
|
|
283
|
+
devise_mailer clean_up_passwords skip_confirmation!
|
|
284
|
+
skip_reconfirmation! valid_password? update_with_password
|
|
285
|
+
destroy_with_password remember_me! forget_me!
|
|
286
|
+
unauthenticated_message confirmation_period_valid?
|
|
287
|
+
pending_reconfirmation? reconfirmation_required?
|
|
288
|
+
send_email_changed_notification send_password_change_notification
|
|
289
|
+
].to_set.freeze
|
|
290
|
+
|
|
211
291
|
def extract_public_instance_methods(model)
|
|
212
292
|
generated = generated_association_methods(model)
|
|
213
293
|
|
|
214
|
-
|
|
294
|
+
# Prioritize source-defined methods
|
|
295
|
+
source_methods = extract_source_instance_methods(model)
|
|
296
|
+
|
|
297
|
+
all_methods = (model.instance_methods - ActiveRecord::Base.instance_methods - Object.instance_methods)
|
|
215
298
|
.reject { |m|
|
|
216
299
|
ms = m.to_s
|
|
217
|
-
ms.start_with?("_", "autosave", "validate_associated") ||
|
|
300
|
+
ms.start_with?("_", "autosave", "validate_associated") ||
|
|
301
|
+
generated.include?(ms) ||
|
|
302
|
+
DEVISE_INSTANCE_PATTERNS.include?(ms) ||
|
|
303
|
+
ms.match?(/\Awill_save_change_to_|_before_last_save\z|_in_database\z|_before_type_cast\z/)
|
|
218
304
|
}
|
|
219
|
-
.sort
|
|
220
|
-
.first(30)
|
|
221
305
|
.map(&:to_s)
|
|
306
|
+
.sort
|
|
307
|
+
|
|
308
|
+
# Source-defined methods first
|
|
309
|
+
ordered = source_methods + (all_methods - source_methods)
|
|
310
|
+
ordered.first(30)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def extract_source_instance_methods(model)
|
|
314
|
+
path = model_source_path(model)
|
|
315
|
+
return [] unless path && File.exist?(path)
|
|
316
|
+
|
|
317
|
+
source = File.read(path)
|
|
318
|
+
methods = []
|
|
319
|
+
in_private = false
|
|
320
|
+
source.each_line do |line|
|
|
321
|
+
in_private = true if line.match?(/\A\s*private\s*$/)
|
|
322
|
+
next if in_private
|
|
323
|
+
next if line.match?(/\A\s*def self\./)
|
|
324
|
+
if (match = line.match(/\A\s*def (\w+[?!]?)/))
|
|
325
|
+
methods << match[1] unless match[1] == "initialize"
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
methods.uniq
|
|
329
|
+
rescue
|
|
330
|
+
[]
|
|
222
331
|
end
|
|
223
332
|
|
|
224
333
|
# Build list of AR-generated association helper method names to exclude
|
|
@@ -128,15 +128,31 @@ module RailsAiContext
|
|
|
128
128
|
pk_display = pk.is_a?(Array) ? pk.join(", ") : (pk || "id").to_s
|
|
129
129
|
|
|
130
130
|
# Show column names WITH types for key columns
|
|
131
|
+
# Skip standard Rails FK columns (like user_id, account_id) but keep
|
|
132
|
+
# external ID columns (like paymongo_checkout_id, stripe_payment_id)
|
|
133
|
+
fk_columns = (data[:foreign_keys] || []).map { |f| f[:column] }.to_set
|
|
134
|
+
all_table_names = tables.keys.to_set
|
|
131
135
|
key_cols = columns.select do |c|
|
|
132
136
|
next true if keep_cols.include?(c[:name])
|
|
133
137
|
next true if c[:name].end_with?("_type")
|
|
134
138
|
next false if skip_cols.include?(c[:name])
|
|
135
|
-
|
|
139
|
+
if c[:name].end_with?("_id")
|
|
140
|
+
# Skip if it's a known FK or matches a table name (conventional Rails FK)
|
|
141
|
+
ref_table = c[:name].sub(/_id\z/, "").pluralize
|
|
142
|
+
next false if fk_columns.include?(c[:name]) || all_table_names.include?(ref_table)
|
|
143
|
+
end
|
|
136
144
|
true
|
|
137
145
|
end
|
|
138
146
|
|
|
139
|
-
col_sample = key_cols.map
|
|
147
|
+
col_sample = key_cols.map do |c|
|
|
148
|
+
col_type = c[:array] ? "#{c[:type]}[]" : c[:type].to_s
|
|
149
|
+
entry = "#{c[:name]}:#{col_type}"
|
|
150
|
+
if c.key?(:default) && !c[:default].nil?
|
|
151
|
+
default_display = c[:default] == "" ? '""' : c[:default]
|
|
152
|
+
entry += "(=#{default_display})"
|
|
153
|
+
end
|
|
154
|
+
entry
|
|
155
|
+
end
|
|
140
156
|
col_str = col_sample.any? ? " — #{col_sample.join(', ')}" : ""
|
|
141
157
|
|
|
142
158
|
# Foreign keys
|
|
@@ -27,7 +27,13 @@ module RailsAiContext
|
|
|
27
27
|
|
|
28
28
|
# --- Models ---
|
|
29
29
|
models = ctx[:models] || {}
|
|
30
|
-
matched_models = models.select
|
|
30
|
+
matched_models = models.select do |name, data|
|
|
31
|
+
next false if data[:error]
|
|
32
|
+
# Match on model name, table name, or underscore form
|
|
33
|
+
name.downcase.include?(pattern) ||
|
|
34
|
+
data[:table_name]&.downcase&.include?(pattern) ||
|
|
35
|
+
name.underscore.include?(pattern)
|
|
36
|
+
end
|
|
31
37
|
|
|
32
38
|
if matched_models.any?
|
|
33
39
|
lines << "## Models (#{matched_models.size} matched)"
|
|
@@ -137,17 +137,29 @@ module RailsAiContext
|
|
|
137
137
|
# Validations — compress repeated inclusion lists, deduplicate same kind+attribute
|
|
138
138
|
if data[:validations]&.any?
|
|
139
139
|
lines << "" << "## Validations"
|
|
140
|
-
#
|
|
140
|
+
# Identify belongs_to association names for labeling implicit validations
|
|
141
|
+
belongs_to_names = (data[:associations] || [])
|
|
142
|
+
.select { |a| a[:type] == "belongs_to" && a[:optional] != true }
|
|
143
|
+
.map { |a| a[:name] }
|
|
144
|
+
.to_set
|
|
145
|
+
|
|
146
|
+
# Deduplicate validations with same kind and attributes
|
|
141
147
|
seen_validations = Set.new
|
|
142
|
-
# Track seen inclusion arrays to avoid repeating long lists
|
|
143
148
|
seen_inclusions = {}
|
|
144
149
|
data[:validations].each do |v|
|
|
145
150
|
dedup_key = "#{v[:kind]}:#{v[:attributes].sort.join(',')}"
|
|
146
151
|
next if seen_validations.include?(dedup_key)
|
|
147
152
|
seen_validations << dedup_key
|
|
148
153
|
attrs = v[:attributes].join(", ")
|
|
154
|
+
|
|
155
|
+
# Label implicit belongs_to presence validations
|
|
156
|
+
implicit = v[:kind] == "presence" && v[:attributes].size == 1 && belongs_to_names.include?(v[:attributes].first)
|
|
157
|
+
implicit_label = implicit ? " _(implicit from belongs_to)_" : ""
|
|
158
|
+
|
|
149
159
|
if v[:options]&.any?
|
|
150
|
-
|
|
160
|
+
# Filter out message: "required" from implicit belongs_to validations
|
|
161
|
+
filtered_opts = v[:options].reject { |k, val| implicit && k.to_s == "message" && val.to_s == "required" }
|
|
162
|
+
compressed_opts = filtered_opts.map do |k, val|
|
|
151
163
|
if k.to_s == "in" && val.is_a?(Array) && val.size > 3
|
|
152
164
|
key = val.sort.join(",")
|
|
153
165
|
if seen_inclusions[key]
|
|
@@ -160,11 +172,11 @@ module RailsAiContext
|
|
|
160
172
|
"#{k}: #{val}"
|
|
161
173
|
end
|
|
162
174
|
end
|
|
163
|
-
opts = " (#{compressed_opts.join(', ')})"
|
|
175
|
+
opts = compressed_opts.any? ? " (#{compressed_opts.join(', ')})" : ""
|
|
164
176
|
else
|
|
165
177
|
opts = ""
|
|
166
178
|
end
|
|
167
|
-
lines << "- `#{v[:kind]}` on #{attrs}#{opts}"
|
|
179
|
+
lines << "- `#{v[:kind]}` on #{attrs}#{opts}#{implicit_label}"
|
|
168
180
|
end
|
|
169
181
|
end
|
|
170
182
|
|
|
@@ -149,7 +149,11 @@ module RailsAiContext
|
|
|
149
149
|
nullable = col.key?(:null) ? (col[:null] ? "yes" : "**NO**") : "yes"
|
|
150
150
|
col_type = col[:array] ? "#{col[:type]}[]" : col[:type].to_s
|
|
151
151
|
line = "| #{col[:name]} | #{col_type} | #{nullable}"
|
|
152
|
-
|
|
152
|
+
if has_defaults
|
|
153
|
+
default_val = col[:default]
|
|
154
|
+
display_default = default_val == "" ? '""' : default_val
|
|
155
|
+
line += " | #{display_default}"
|
|
156
|
+
end
|
|
153
157
|
lines << "#{line} |"
|
|
154
158
|
end
|
|
155
159
|
|