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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e7442b6a28d089867479cba4a99d6f0fcf64e34f692d8c47b576de8e7ec4ffd
4
- data.tar.gz: 0b8b21e8c1581a056afbb03e3ef6b3c45a62c7fbb61d345bbec49d370c4fd191
3
+ metadata.gz: 5d0ee652e69687c50687cd1b13209688aa5eecc24cf892fa807f9a3d03c7f2de
4
+ data.tar.gz: e689153ebb668ead61eb375c551a4724c73ce9338ae78a48b6acae63b9a63266
5
5
  SHA512:
6
- metadata.gz: e1bd3b280678917a3b94576cef271669fc6892ca32cfd522b88a4c7cee93d73d4301b6d995bd75e78fd64bb448f5e226013b8678411bfd56e68de0a59ccba35b
7
- data.tar.gz: a57fed3f44c4e1599854caf8243a35f62476f487e8b14a41489bcaee1efb4e8398935211ea9073ca70f9a1e34c16a6fa13d79cdaa5cf943d6f0d5d2e82ea100c
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.sort_by(&:name)
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
- (model.methods - ActiveRecord::Base.methods - Object.methods)
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") || scope_names.include?(ms)
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
- (model.instance_methods - ActiveRecord::Base.instance_methods - Object.instance_methods)
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") || generated.include?(ms)
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
- next false if c[:name].end_with?("_id")
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 { |c| "#{c[:name]}:#{c[:type]}" }
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 { |name, data| !data[:error] && name.downcase.include?(pattern) }
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
- # Deduplicate validations with same kind and attributes (e.g. implicit belongs_to + explicit validates :user, presence)
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
- compressed_opts = v[:options].map do |k, val|
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
- line += " | #{col[:default]}" if has_defaults
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "1.2.0"
4
+ VERSION = "1.2.1"
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: 1.2.0
4
+ version: 1.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine