rails-ai-context 5.21.2 → 5.21.3

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: 986161d7fdb3003eca77d6f88b2820b5b76a35d72b5282d8f910b0e316b29298
4
- data.tar.gz: 2fc004a71ff022abd1c1d216466936b27249c5da6dede308dece659b3cc311a8
3
+ metadata.gz: 8afb263c0004a822a803eee097300e87dcb5a3a2de27d996a3cc5f11ea801051
4
+ data.tar.gz: 462a8ca8d7f4a66cd617af71322356908f513aa88ac7aec2046f32cf4279805f
5
5
  SHA512:
6
- metadata.gz: 4c335f590a135a3d8591bd52daef9673a59b4b9af150aebbe80cf91a39905098c7e215f5e77dea0dd07abf1aa9d94c96ac3586a2d487f0955ab3eb6220f51bc5
7
- data.tar.gz: 230525933f4ccd784307c70ec97785b3b7df2cafedcb4720b9d7ae5d6b95239693ecd9e11d0651215ce177a4cf8e1144ef85dee82a061f62b21f0252e1fe5523
6
+ metadata.gz: 2f48e61abb15f942c9a3b41eced790ce41618f091e337d84c1d598486e74e3a58e3331c9acf971ad2fea0d0a0bb8a909caef4c6b9fd3196fba51dbe318532e97
7
+ data.tar.gz: 07e4b2759758da12d51805c0861e8a101fcdefdff55cba0db7714c6464473e486842fbd93c55b6480c8ce7b87325138fa18eb0920c38e219f0df3556a1ec0b19
data/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ 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
+ ## [5.21.3] - 2026-08-12
9
+
10
+ ### Fixed
11
+
12
+ - **Foreign keys name the column that was declared, not one invented from the
13
+ target table.** `add_foreign_key "accounts", "accounts", column:
14
+ "moved_to_account_id"` was reported as `account_id`, a column the table does
15
+ not have, and two such keys on one table collided into the same invented
16
+ name. The schema listener dropped `column:`/`primary_key:` entirely, so the
17
+ convention was the only answer; it is now the fallback for the case Rails
18
+ omits the option in. The migration-replay path invented `statuse_id` from
19
+ `statuses` for the same reason. (#140)
20
+ - **A service's constructor is read from the service, not from the first
21
+ nested class in the file.** `rails_get_service_pattern` matched the first
22
+ `def initialize(` anywhere in the source, so a nested error class or query
23
+ builder supplied the signature for a service that defines no constructor,
24
+ and an agent reading it wrote `PostStatusService.new(message, accounts)` for
25
+ a class whose real interface is `.new.call(...)`. The constructor now comes
26
+ from the AST walk that already resolves the interface owner, and a
27
+ parenthesis-less `def initialize` is no longer missed. (#141)
28
+ - **Autoloader root_dirs are gem-relative, like initializer sources.** #139
29
+ fixed the sources and left `autoload.autoloaders[].root_dirs`, where every
30
+ engine's paths land, still absolute in `.ai-context.json`. Both introspectors
31
+ now share one `PortablePath`, which also collapses a gem the Gemfile takes
32
+ from `path:` or `git:`. A guard spec walks the whole generated context for
33
+ paths from the generating machine. (#142)
34
+ - **A model's `*_url` and `*_path` columns are not reported as missing route
35
+ helpers.** `rails_validate` read every receiverless call ending in `_url` as
36
+ a route helper, and an attribute reader has no `def` for the local-method
37
+ escape to find, so `shared_inbox_url` on Mastodon's `accounts` warned as a
38
+ broken route. The check now consults the model's own column list. (#143)
39
+
8
40
  ## [5.21.2] - 2026-08-12
9
41
 
10
42
  ### Fixed
data/CONTEXT.md CHANGED
@@ -20,6 +20,14 @@ Avoid "format" for either sense - claude is not a file format, and the word coll
20
20
 
21
21
  `Install::AiTool` is the one table of what each means: name, context files, MCP config shape, legacy leftovers. `Install::SelectionRecord` owns which ones the user picked - written to YAML always and to the initializer line inside a Rails app, read initializer-first because that is the file a user hand-edits.
22
22
 
23
+ ## Path
24
+
25
+ Two senses, one per module, and neither is bare "path" in a name.
26
+
27
+ **Where code lives** - `PathResolver` answers which directories a kind of app code can occupy for a given root: conventional layout, packwerk packs, in-repo engines, configured extras.
28
+
29
+ **How a path is written down** - `PortablePath` rewrites one so it means the same thing on another machine, because what it touches ends up in `.ai-context.json` and the app commits that file. App paths go app-relative, gem paths keep the gem and version and drop the install prefix. "Relativize" always means this.
30
+
23
31
  ## Static tier
24
32
 
25
33
  The mode where the app did not boot, or `--no-boot` was passed. What an introspector answers here is what it declared, never what a runtime check happened to detect. Every introspector in `INTROSPECTOR_MAP` extends `StaticTier` and names one of three kinds:
@@ -184,7 +184,7 @@ It runs a single-pass Dispatcher that walks the AST once and feeds events to all
184
184
  | EnumsListener | Rails 7+ and legacy enum syntax, prefix/suffix options |
185
185
  | CallbacksListener | All AR callback types, `after_commit` with `on:` resolution |
186
186
  | MacrosListener | `encrypts`, `normalizes`, `delegate`, `has_secure_password`, `serialize`, `store`, `has_one_attached`, `has_many_attached`, `has_rich_text`, `generates_token_for`, `attribute` |
187
- | MethodsListener | `def`/`def self.`, visibility tracking, parameter extraction, `class << self` |
187
+ | MethodsListener | `def`/`def self.`, visibility tracking, parameter extraction, `class << self`; `include_initialize: true` adds the constructor a caller reports on its own |
188
188
  | MixinsListener | `include`, `prepend`, `extend`, flagging the ones that reach the ancestor chain |
189
189
 
190
190
  ### The targeted-walk listeners
@@ -136,12 +136,10 @@ module RailsAiContext
136
136
  "#{hit[:method]}: #{values.first}"
137
137
  end
138
138
 
139
- # Rails lists a path once per railtie that contributed it.
139
+ # Rails lists a path once per railtie that contributed it, and every
140
+ # engine's paths sit under the machine's gem prefix rather than the app.
140
141
  def relativize(paths)
141
- Array(paths).map { |p|
142
- s = p.to_s
143
- s.start_with?(root) ? s.sub("#{root}/", "") : s
144
- }.uniq
142
+ PortablePath.relativize_all(paths, root)
145
143
  end
146
144
  end
147
145
  end
@@ -110,29 +110,11 @@ module RailsAiContext
110
110
  return nil unless loc
111
111
 
112
112
  path, line = loc
113
- "#{relativize_source(path)}:#{line}"
113
+ "#{PortablePath.relativize(path, root)}:#{line}"
114
114
  rescue => e
115
115
  $stderr.puts "[rails-ai-context] block_source_location failed: #{e.message}" if ENV["DEBUG"]
116
116
  nil
117
117
  end
118
-
119
- # These sources are written into .ai-context.json, which the app commits.
120
- # An absolute path is wrong on every other machine and carries the
121
- # generating developer's home directory, so app paths go app-relative and
122
- # gem paths keep the gem and version instead of the install prefix.
123
- def relativize_source(path)
124
- return path.sub("#{root}/", "") if path.start_with?(root)
125
-
126
- gem_roots.each do |gem_root|
127
- return path.delete_prefix(gem_root) if path.start_with?(gem_root)
128
- end
129
- path
130
- end
131
-
132
- def gem_roots
133
- @gem_roots ||= (Gem.path + [ Gem.default_dir ]).compact.uniq
134
- .map { |dir| File.join(dir, "gems") + File::SEPARATOR }
135
- end
136
118
  end
137
119
  end
138
120
  end
@@ -8,8 +8,11 @@ module RailsAiContext
8
8
  # visibility modifier calls, including inline forms like
9
9
  # `private :method_name`.
10
10
  class MethodsListener < BaseListener
11
- def initialize
12
- super
11
+ # A constructor is not part of the callable interface every other
12
+ # caller asks for, so it stays out unless one asks for it by name.
13
+ def initialize(include_initialize: false)
14
+ super()
15
+ @include_initialize = include_initialize
13
16
  @visibility_stack = [ :public ]
14
17
  @in_singleton_class = false
15
18
  @singleton_depth = 0
@@ -84,8 +87,7 @@ module RailsAiContext
84
87
  is_class_method = @in_singleton_class || node.receiver&.is_a?(Prism::SelfNode)
85
88
  method_name = node.name.to_s
86
89
 
87
- # Skip initialize for instance methods
88
- return if method_name == "initialize" && !is_class_method
90
+ return if method_name == "initialize" && !is_class_method && !@include_initialize
89
91
 
90
92
  # Inline visibility (`private :foo`) takes precedence over positional
91
93
  visibility = @inline_visibility_stack.last[method_name] || @visibility_stack.last
@@ -64,12 +64,18 @@ module RailsAiContext
64
64
  to_arg = args[1]
65
65
  return unless from_arg.is_a?(Prism::StringNode) && to_arg.is_a?(Prism::StringNode)
66
66
 
67
+ options = extract_keyword_options(node)
68
+
67
69
  @results << {
68
- type: :foreign_key,
69
- from: from_arg.unescaped,
70
- to: to_arg.unescaped,
71
- location: node.location.start_line
72
- }
70
+ type: :foreign_key,
71
+ from: from_arg.unescaped,
72
+ to: to_arg.unescaped,
73
+ # Absent means the Rails convention holds; naming it here would
74
+ # make a declared column indistinguishable from a guessed one.
75
+ column: options[:column]&.to_s,
76
+ primary_key: options[:primary_key]&.to_s,
77
+ location: node.location.start_line
78
+ }.compact
73
79
  end
74
80
 
75
81
  def extract_enum(node)
@@ -298,6 +298,17 @@ module RailsAiContext
298
298
  entry
299
299
  end
300
300
 
301
+ # Rails omits column:/primary_key: only where the convention holds, so
302
+ # the fallback is what was declared rather than a guess. Both dump
303
+ # readers land here so the convention lives in one place.
304
+ def foreign_key_entry(from, to, column, primary_key)
305
+ {
306
+ from_table: from, to_table: to,
307
+ column: column&.to_s || "#{to.to_s.singularize}_id",
308
+ primary_key: primary_key&.to_s || "id"
309
+ }
310
+ end
311
+
301
312
  def static_index(index)
302
313
  columns = index[:columns]
303
314
  return nil if columns.empty?
@@ -330,12 +341,9 @@ module RailsAiContext
330
341
  end
331
342
 
332
343
  schema.foreign_keys.each do |fk|
333
- # SchemaDslListener doesn't capture column/primary_key options for
334
- # add_foreign_key; fall back to convention.
335
- tables[fk[:from]]&.dig(:foreign_keys)&.push({
336
- from_table: fk[:from], to_table: fk[:to],
337
- column: "#{fk[:to].singularize}_id", primary_key: "id"
338
- })
344
+ tables[fk[:from]]&.dig(:foreign_keys)&.push(
345
+ foreign_key_entry(fk[:from], fk[:to], fk[:column], fk[:primary_key])
346
+ )
339
347
  end
340
348
 
341
349
  check_constraints = schema.check_constraints
@@ -815,8 +823,7 @@ module RailsAiContext
815
823
  to = entry[:to_table]
816
824
  return unless tables[from]
817
825
  opts = entry[:options] || {}
818
- column = opts[:column]&.to_s || "#{to&.to_s&.chomp('s')}_id"
819
- tables[from][:foreign_keys] << { from_table: from, to_table: to, column: column, primary_key: "id" }
826
+ tables[from][:foreign_keys] << foreign_key_entry(from, to, opts[:column], opts[:primary_key])
820
827
  end
821
828
  end
822
829
 
@@ -117,7 +117,10 @@ module RailsAiContext
117
117
  when :add_index
118
118
  schema[:tables][event[:table]]&.dig(:indexes)&.push(index_entry(event))
119
119
  when :foreign_key
120
- schema[:foreign_keys] << { from: event[:from], to: event[:to] }
120
+ schema[:foreign_keys] << {
121
+ from: event[:from], to: event[:to],
122
+ column: event[:column], primary_key: event[:primary_key]
123
+ }.compact
121
124
  when :enum
122
125
  schema[:enums] << { name: event[:name], values: event[:values] }
123
126
  when :check_constraint
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ # Rewrites a path so it means the same thing on another machine. What is
5
+ # generated here ends up in .ai-context.json, which the app commits: an
6
+ # absolute path is wrong on every other checkout, wrong again after a Ruby
7
+ # or gem upgrade, and it carries the generating developer's home directory
8
+ # into the repository. App paths go app-relative; gem paths keep the gem
9
+ # and version and drop the install prefix.
10
+ #
11
+ # Distinct from PathResolver, which answers where a kind of app code lives.
12
+ module PortablePath
13
+ module_function
14
+
15
+ def relativize(path, root)
16
+ path = path.to_s
17
+ root = root.to_s
18
+ return path.delete_prefix("#{root}/") if !root.empty? && path.start_with?("#{root}/")
19
+
20
+ gem_roots.each do |gem_root|
21
+ return path.delete_prefix(gem_root) if path.start_with?(gem_root)
22
+ end
23
+
24
+ gem_checkouts.each do |dir, name|
25
+ return File.join(name, path.delete_prefix(dir)) if path.start_with?(dir)
26
+ end
27
+ path
28
+ end
29
+
30
+ def relativize_all(paths, root)
31
+ Array(paths).map { |path| relativize(path, root) }.uniq
32
+ end
33
+
34
+ # Every prefix a gem can be unpacked under, trailing separator included so
35
+ # a prefix cannot match a sibling directory that merely starts the same.
36
+ def gem_roots
37
+ @gem_roots ||= (Gem.path + [ Gem.default_dir ]).compact.uniq
38
+ .map { |dir| File.join(dir, "gems") + File::SEPARATOR }
39
+ end
40
+
41
+ # A gem the Gemfile takes from path: or git: is never unpacked under a
42
+ # gem root, so its own checkout is the only prefix that names it. Longest
43
+ # first, because one checkout can sit inside another.
44
+ def gem_checkouts
45
+ @gem_checkouts ||= Gem.loaded_specs.each_value.filter_map { |spec|
46
+ dir = spec.full_gem_path.to_s
47
+ next if dir.empty? || gem_roots.any? { |gem_root| dir.start_with?(gem_root) }
48
+ [ dir + File::SEPARATOR, spec.full_name ]
49
+ }.sort_by { |dir, _name| -dir.length }
50
+ rescue => e
51
+ $stderr.puts "[rails-ai-context] PortablePath.gem_checkouts failed: #{e.message}" if ENV["DEBUG"]
52
+ []
53
+ end
54
+ end
55
+ end
@@ -81,12 +81,14 @@ module RailsAiContext
81
81
  lines = [ "# #{class_name}", "" ]
82
82
  lines << "**File:** `#{relative}` (#{count_phrase(line_count, "line")})"
83
83
 
84
+ owned = owned_methods(source, constant_for(file, services_dir))
85
+
84
86
  # Initialize params
85
- init_params = extract_initialize_params(source)
87
+ init_params = extract_initialize_params(owned)
86
88
  lines << "**Initialize:** `#{init_params}`" if init_params
87
89
 
88
90
  # Public methods
89
- public_methods = extract_public_methods(source, constant_for(file, services_dir))
91
+ public_methods = extract_public_methods(owned)
90
92
  if public_methods.any?
91
93
  lines << "" << "## Public Methods"
92
94
  public_methods.each { |m| lines << "- `#{m}`" }
@@ -139,20 +141,23 @@ module RailsAiContext
139
141
  relative = file.sub("#{root}/", "")
140
142
  class_name = extract_class_name(source) || File.basename(file, ".rb").camelize
141
143
  line_count = source.lines.size
142
- public_methods = extract_public_methods(source, constant_for(file, services_dir))
144
+ owned = owned_methods(source, constant_for(file, services_dir))
145
+ public_methods = extract_public_methods(owned)
146
+ init_params = extract_initialize_params(owned)
143
147
 
144
148
  pattern_stats[:total] += 1
145
- has_initialize = source.match?(/def initialize/)
149
+ has_initialize = !init_params.nil?
146
150
  pattern_stats[:initialize_call] += 1 if has_initialize && public_methods.any? { |m| m.start_with?("call") }
147
151
  pattern_stats[:initialize_single_method] += 1 if has_initialize && public_methods.size == 1
148
- pattern_stats[:class_method_call] += 1 if source.match?(/def self\.call/)
152
+ pattern_stats[:class_method_call] += 1 if owned.any? { |m| m[:scope] == :class && m[:name] == "call" }
149
153
  pattern_stats[:result_object] += 1 if source.match?(/Result\.new|OpenStruct\.new|Struct\.new|\.success|\.failure/)
150
154
 
151
155
  service_data << {
152
156
  file: relative,
153
157
  class_name: class_name,
154
158
  line_count: line_count,
155
- public_methods: public_methods
159
+ public_methods: public_methods,
160
+ init_params: init_params
156
161
  }
157
162
  end
158
163
 
@@ -183,13 +188,12 @@ module RailsAiContext
183
188
  methods_str = s[:public_methods].any? ? s[:public_methods].join(", ") : "none"
184
189
  lines << "- **Methods:** #{methods_str}"
185
190
 
191
+ lines << "- **Initialize:** `#{s[:init_params]}`" if s[:init_params]
192
+
186
193
  # Read source for additional detail
187
194
  full_path = File.join(root, s[:file])
188
195
  source = safe_read(full_path)
189
196
  if source
190
- init_params = extract_initialize_params(source)
191
- lines << "- **Initialize:** `#{init_params}`" if init_params
192
-
193
197
  side_effects = extract_side_effects(source)
194
198
  lines << "- **Side effects:** #{side_effects.join(', ')}" if side_effects.any?
195
199
 
@@ -216,17 +220,11 @@ module RailsAiContext
216
220
  match[1] if match
217
221
  end
218
222
 
219
- private_class_method def self.extract_initialize_params(source)
220
- match = source.match(/def initialize\(([^)]*)\)/m)
221
- return nil unless match
222
- "initialize(#{match[1].strip})"
223
- end
224
-
225
- # A service's interface is the public methods of the service class
226
- # itself. Nesting a query builder or a set of condition objects inside
227
- # the service that uses them is a normal way to organise a large one, and
228
- # a line scan cannot see that: it reported the nested class's first `def`
229
- # as the entry point, and a `private` inside a nested class hid the real
223
+ # The methods the service class defines itself. Nesting a query builder
224
+ # or a set of condition objects inside the service that uses them is a
225
+ # normal way to organise a large one, and a line scan cannot see that:
226
+ # it reported the nested class's first `def` as the entry point, its
227
+ # constructor as the service's, and a `private` inside it hid the real
230
228
  # `call` that followed the nested class's `end`.
231
229
  #
232
230
  # `expected_constant` comes from the path rather than the source, the way
@@ -234,22 +232,36 @@ module RailsAiContext
234
232
  # and only the path carries the namespace. A file that does not follow
235
233
  # the convention falls back to the shallowest nesting, which is the
236
234
  # outermost definition in the file.
237
- private_class_method def self.extract_public_methods(source, expected_constant = nil)
235
+ #
236
+ # One walk per file: the interface and the constructor have to resolve
237
+ # the same owner, and a second walk could pick a different one.
238
+ private_class_method def self.owned_methods(source, expected_constant = nil)
238
239
  ast = Introspectors::SourceIntrospector.walk_source(
239
- source, { methods: Introspectors::Listeners::MethodsListener }
240
+ source, { methods: -> { Introspectors::Listeners::MethodsListener.new(include_initialize: true) } }
240
241
  )
241
242
  methods = ast[:methods] || []
242
243
  return [] if methods.empty?
243
244
 
244
245
  owner = primary_owner(methods, expected_constant)
245
-
246
- methods.select { |m| m[:visibility] == :public && m[:owner].join("::") == owner }
247
- .map { |m| m[:signature] }
246
+ methods.select { |m| m[:owner].join("::") == owner }
248
247
  rescue => e
249
- $stderr.puts "[rails-ai-context] extract_public_methods AST failed: #{e.message}" if ENV["DEBUG"]
248
+ $stderr.puts "[rails-ai-context] owned_methods AST failed: #{e.message}" if ENV["DEBUG"]
250
249
  []
251
250
  end
252
251
 
252
+ # A service that defines no constructor answers `.new` with no arguments.
253
+ private_class_method def self.extract_initialize_params(owned)
254
+ ctor = owned.find { |m| m[:name] == "initialize" && m[:scope] == :instance }
255
+ ctor && ctor[:signature]
256
+ end
257
+
258
+ # The constructor is reported on its own line, not as part of the
259
+ # interface, which is why it is dropped here rather than at the walk.
260
+ private_class_method def self.extract_public_methods(owned)
261
+ owned.select { |m| m[:visibility] == :public && m[:name] != "initialize" }
262
+ .map { |m| m[:signature] }
263
+ end
264
+
253
265
  private_class_method def self.primary_owner(methods, expected_constant)
254
266
  owners = methods.map { |m| m[:owner].join("::") }
255
267
  return expected_constant if expected_constant && owners.include?(expected_constant)
@@ -203,23 +203,23 @@ module RailsAiContext
203
203
  if file.end_with?(".html.erb", ".erb")
204
204
  if visitor
205
205
  warnings.concat(check_partial_existence_ast(file, visitor))
206
- warnings.concat(check_route_helpers_ast(visitor, context))
206
+ warnings.concat(check_route_helpers_ast(file, visitor, context))
207
207
  else
208
208
  warnings.concat(check_partial_existence_regex(file, content))
209
- warnings.concat(check_route_helpers_regex(content, context))
209
+ warnings.concat(check_route_helpers_regex(file, content, context))
210
210
  end
211
211
  warnings.concat(check_stimulus_controllers(content, context))
212
212
  warnings.concat(check_instance_variable_usage(file, content, context))
213
213
  warnings.concat(check_respond_to_template_existence(file, content))
214
214
  elsif file.end_with?(".rb")
215
215
  if visitor
216
- warnings.concat(check_route_helpers_ast(visitor, context))
216
+ warnings.concat(check_route_helpers_ast(file, visitor, context))
217
217
  warnings.concat(check_partial_existence_ast(file, visitor, qualified_only: true))
218
218
  warnings.concat(check_column_references_ast(file, visitor, context))
219
219
  warnings.concat(check_strong_params_ast(file, visitor, context))
220
220
  warnings.concat(check_callback_existence_ast(file, visitor, context))
221
221
  else
222
- warnings.concat(check_route_helpers_regex(content, context))
222
+ warnings.concat(check_route_helpers_regex(file, content, context))
223
223
  warnings.concat(check_column_references_regex(file, content, context))
224
224
  end
225
225
  # Cache-only checks (no AST needed)
@@ -313,19 +313,22 @@ module RailsAiContext
313
313
  ASSET_HELPER_PREFIXES = %w[image asset font stylesheet javascript audio video file compute_asset auto_discovery_link favicon].freeze
314
314
  DEVISE_HELPER_NAMES = %w[session registration password confirmation unlock omniauth_callback user_session user_registration user_password user_confirmation user_unlock].freeze
315
315
 
316
- private_class_method def self.check_route_helpers_ast(visitor, context)
316
+ private_class_method def self.check_route_helpers_ast(file, visitor, context)
317
317
  warnings = []
318
318
  routes = context[:routes]
319
319
  return warnings unless routes && routes[:by_controller]
320
320
  valid_names = build_route_name_set(routes)
321
321
  return warnings if valid_names.empty?
322
322
 
323
+ columns = helper_shaped_columns(file, context)
324
+
323
325
  seen = Set.new
324
326
  visitor.route_helper_calls.each do |call|
325
327
  helper = call[:name]
326
328
  next if seen.include?(helper)
327
329
  seen << helper
328
330
  next if visitor.local_route_method_defined?(helper, call[:scope], call[:method_kind])
331
+ next if columns.include?(helper)
329
332
 
330
333
  name = helper.sub(/_(path|url)\z/, "")
331
334
  next if ASSET_HELPER_PREFIXES.any? { |p| name.start_with?(p) }
@@ -338,13 +341,15 @@ module RailsAiContext
338
341
  end
339
342
 
340
343
  # Regex fallback
341
- private_class_method def self.check_route_helpers_regex(content, context)
344
+ private_class_method def self.check_route_helpers_regex(file, content, context)
342
345
  warnings = []
343
346
  routes = context[:routes]
344
347
  return warnings unless routes && routes[:by_controller]
345
348
  valid_names = build_route_name_set(routes)
346
349
  return warnings if valid_names.empty?
347
350
 
351
+ columns = helper_shaped_columns(file, context)
352
+
348
353
  seen = Set.new
349
354
  local_method_names = local_route_like_method_names(content)
350
355
  content.scan(/\b(\w+)_(path|url)\b/).each do |match|
@@ -353,6 +358,7 @@ module RailsAiContext
353
358
  next if seen.include?(helper)
354
359
  seen << helper
355
360
  next if local_method_names.include?(helper)
361
+ next if columns.include?(helper)
356
362
  next if ASSET_HELPER_PREFIXES.any? { |p| name.start_with?(p) }
357
363
  next if DEVISE_HELPER_NAMES.include?(name)
358
364
  next if %w[edit new polymorphic].include?(name)
@@ -361,6 +367,18 @@ module RailsAiContext
361
367
  warnings
362
368
  end
363
369
 
370
+ # A column named `shared_inbox_url` reads as a receiverless call to a
371
+ # route helper, and the attribute reader it resolves to has no `def`
372
+ # anywhere to find it by. Columns only: an association named like a
373
+ # helper is not a reader, and suppressing on it would hide a real
374
+ # missing route.
375
+ private_class_method def self.helper_shaped_columns(file, context)
376
+ valid = model_valid_columns(file, context)
377
+ return Set.new unless valid
378
+
379
+ valid[:table_columns].select { |c| c.to_s.end_with?("_path", "_url") }.to_set
380
+ end
381
+
364
382
  private_class_method def self.local_route_like_method_names(content)
365
383
  content.scan(/^\s*(?:(?:private|protected|public|private_class_method)\s+)*def\s+(?:self\.)?(\w+_(?:path|url))\b/).flatten.to_set
366
384
  end
@@ -432,14 +450,16 @@ module RailsAiContext
432
450
  table_data = schema[:tables] && schema[:tables][table_name]
433
451
  return nil unless table_data
434
452
 
435
- columns = Set.new
436
- table_data[:columns]&.each { |c| columns << c[:name] }
453
+ table_columns = Set.new
454
+ table_data[:columns]&.each { |c| table_columns << c[:name] }
455
+
456
+ columns = table_columns.dup
437
457
  model_data[:associations]&.each do |a|
438
458
  columns << a[:name] if a[:name]
439
459
  columns << a[:foreign_key] if a[:foreign_key]
440
460
  end
441
461
 
442
- { columns: columns, table: table_name, model: model_name, model_data: model_data }
462
+ { columns: columns, table_columns: table_columns, table: table_name, model: model_name, model_data: model_data }
443
463
  end
444
464
 
445
465
  # ── CHECK 4: Strong params vs schema (AST) ───────────────────────
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.21.2"
4
+ VERSION = "5.21.3"
5
5
  end
data/server.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "url": "https://github.com/crisnahine/rails-ai-context",
8
8
  "source": "github"
9
9
  },
10
- "version": "5.21.1",
10
+ "version": "5.21.2",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "mcpb",
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: 5.21.2
4
+ version: 5.21.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine
@@ -384,6 +384,7 @@ files:
384
384
  - lib/rails_ai_context/output_guard.rb
385
385
  - lib/rails_ai_context/path_resolver.rb
386
386
  - lib/rails_ai_context/polyfill/data.rb
387
+ - lib/rails_ai_context/portable_path.rb
387
388
  - lib/rails_ai_context/presets.rb
388
389
  - lib/rails_ai_context/redaction.rb
389
390
  - lib/rails_ai_context/resources.rb