rails-ai-context 5.20.3 → 5.21.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: 812fc2b5160e39c746e9d399bf6cea5752339f53c57a80f3ce0d01a5e819b7d0
4
- data.tar.gz: 22254f62db474a0f0b7f364f156b3098819b52285e88df2835b32ea25c86972b
3
+ metadata.gz: e2ccd7eb081bc5f6f34e998978e38d7d9d5282d097818224c10bd56ff4d30c75
4
+ data.tar.gz: 0bb8a8ad8f57a11a04a41dd2ab9e30f57f25b08382d1b5edca67af0c3db68f38
5
5
  SHA512:
6
- metadata.gz: 4eddc9f145293be65bc99e9b0dd5334de7570ef217df4dca4f554c191ae314bd6544ed16f49be013a2db93c3394e49f02204c1d76c1c256d7394881ea273eb81
7
- data.tar.gz: 53e469b48920a4baefe955ff90624199f60200a6759d180a02d040d4c1368322d0b9648637fe4a13d20cf1ecc9153d3b7a1b041e1d41ae835c7a8e523cfd4bdf
6
+ metadata.gz: dadae053988700c4c82e841dc5d18c0c22f6584ceef3616e7a520388e57bea89bb1535dc2ca658b5960d3d5d3a11ecbedc912f69386485412c0d833d1c8d1cfa
7
+ data.tar.gz: 07bba392bb191112f18cf14e89147b445078d04b404d0bea9f974e148f42f1ef2ee4d83ccacc7049e132dacf2ad0c1ac17fd4079b779d2dd0d6039c739359092
data/CHANGELOG.md CHANGED
@@ -5,6 +5,71 @@ 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.0] - 2026-08-12
9
+
10
+ ### Fixed
11
+
12
+ - **`get_env` no longer offers a name the app never reads.** An app that builds
13
+ its variable names by interpolation, the normal shape when it carries several
14
+ Redis connections, had `#{prefix}URL` reported as a variable name and
15
+ `defaults[:port]` reported as that variable's default. The scan reads the
16
+ parser now instead of matching whatever sat between two quotes, so a name
17
+ that does not exist until runtime is not offered to someone writing a
18
+ `.env.example`, and a default is printed only when it is a value. Comments
19
+ drop out for free, including one trailing a line that also reads `ENV`.
20
+ (#129)
21
+
22
+ - **`get_controllers` no longer lists `set_locale` as an action.** A controller
23
+ whose own file defines no public method fell back to `action_methods`, which
24
+ subtracts inherited methods only as far as the nearest abstract ancestor:
25
+ everything `ApplicationController` and its concerns define publicly came back
26
+ as an action, so a two-route controller reported 19 of them. The actions of a
27
+ thin subclass now come from the source of the ancestor that defines them.
28
+ Reflection is left for the case that has no source to read, an ancestor this
29
+ app does not own the file for, which is what a controller inheriting from a
30
+ gem or an engine looks like. (#130)
31
+
32
+ - **`get_service_pattern` reports the service's entry point, not a nested
33
+ class's.** Nesting a query builder inside the service that uses it is a
34
+ normal way to organise a large one, and the line scan could not see it: it
35
+ named `QueryBuilder#build` as the entry point of a 312-line service, and a
36
+ `private` inside a nested class hid the real `call` that followed the nested
37
+ class's `end`, reporting the service as having no entry point at all. The
38
+ methods now come from the AST, scoped to the class the file is named for.
39
+ (#131)
40
+
41
+ - **`get_test_info` counts tests, not files.** "Test Counts by Category"
42
+ globbed every `.rb` under a category directory, so four mailer specs sitting
43
+ beside four mailer previews counted as eight tests. Anything a project keeps
44
+ next to its specs inflated the number. (#132)
45
+
46
+ - **`get_concern` finds every concern the app has.** It searched two hardcoded
47
+ directories while `get_active_support` searched five, so one run answered 80
48
+ concerns and 81 concerns for the same app, and the mailer concern only the
49
+ second one found could not be reached by name through the first. Both now
50
+ read one seam, which discovers `app/*/concerns` the way Rails autoloads it,
51
+ so an app that keeps `app/serializers/concerns` is covered too. Concerns are
52
+ grouped and filterable by the directory that owns them. (#133)
53
+
54
+ - **Namespaced Pundit policies keep their namespace.** Policy names came from
55
+ the basename, so `app/policies/admin/collection_policy.rb` and
56
+ `app/policies/collection_policy.rb` both read as `CollectionPolicy`. One name
57
+ was listed twice and the other class appeared nowhere in the generated
58
+ context or in `CLAUDE.md`, including the one that defines `destroy?`. (#135)
59
+
60
+ - **Re-running a release no longer fails on the MCP Registry.** The registry
61
+ answers a repeat version with a 400, and the publish step had no guard, so
62
+ re-running a release that had already succeeded turned the workflow red with
63
+ nothing wrong. It now checks for the version first, the way the RubyGems step
64
+ already did.
65
+
66
+ ### Changed
67
+
68
+ - **`config.concern_paths` replaces concern discovery instead of adding to
69
+ it.** Left unset, which is now the default, every `app/*/concerns` directory
70
+ is discovered. Setting it means those directories and no others, so it can
71
+ narrow as well as reach outside `app/`.
72
+
8
73
  ## [5.20.3] - 2026-08-11
9
74
 
10
75
  ### Fixed
@@ -114,7 +114,7 @@ preset: full
114
114
  | `max_search_results` | Integer | `200` | Maximum search results |
115
115
  | `max_validate_files` | Integer | `50` | Maximum files for validation |
116
116
  | `search_extensions` | Array | `["rb", "js", "erb", "yml", "yaml", "json", "ts", "tsx", "vue", "svelte", "haml", "slim"]` | File extensions to search |
117
- | `concern_paths` | Array | `["app/models/concerns", "app/controllers/concerns"]` | Paths to scan for concerns |
117
+ | `concern_paths` | Array | `nil` (discovers `app/*/concerns`) | Paths to scan for concerns. Setting it replaces discovery, so it can narrow as well as widen |
118
118
  | `frontend_paths` | Array | `nil` (auto-detect) | Override frontend file paths |
119
119
  | `extra_app_paths` | Array | `[]` | Extra directories under the app root to treat as application code |
120
120
 
data/docs/GUIDE.md CHANGED
@@ -1285,8 +1285,10 @@ if defined?(RailsAiContext)
1285
1285
  # File extensions for Ruby fallback search
1286
1286
  # config.search_extensions = %w[rb js erb yml yaml json ts tsx vue svelte haml slim]
1287
1287
 
1288
- # Where to look for concern source files
1289
- # config.concern_paths = %w[app/models/concerns app/controllers/concerns]
1288
+ # Where to look for concern source files. Left unset, every
1289
+ # app/*/concerns directory is discovered. Setting this replaces
1290
+ # that list, so it can narrow as well as reach outside app/.
1291
+ # config.concern_paths = %w[app/models/concerns lib/concerns]
1290
1292
 
1291
1293
  # --- Live reload ---
1292
1294
 
@@ -1352,7 +1354,7 @@ end
1352
1354
  | `excluded_filters` | Array | `verify_authenticity_token`, etc. | Framework filter names hidden from controller output |
1353
1355
  | `excluded_middleware` | Array | standard Rails middleware | Default middleware hidden from config output |
1354
1356
  | `search_extensions` | Array | `rb js erb yml yaml json ts tsx vue svelte haml slim` | File extensions for Ruby fallback search |
1355
- | `concern_paths` | Array | `app/models/concerns app/controllers/concerns` | Where to look for concern source files |
1357
+ | `concern_paths` | Array | `nil` (discovers `app/*/concerns`) | Where to look for concern source files. Setting it replaces discovery |
1356
1358
 
1357
1359
  ### Root file generation
1358
1360
 
@@ -311,8 +311,10 @@ module RailsAiContext
311
311
  # File extensions for fallback search (when ripgrep unavailable)
312
312
  # config.search_extensions = %w[rb js erb yml yaml json ts tsx vue svelte haml slim]
313
313
 
314
- # Where to look for concern source files
315
- # config.concern_paths = %w[app/models/concerns app/controllers/concerns]
314
+ # Where to look for concern source files. Left unset, every
315
+ # app/*/concerns directory is discovered. Setting this replaces
316
+ # that list, so it can narrow as well as reach outside app/.
317
+ # config.concern_paths = %w[app/models/concerns lib/concerns]
316
318
  SECTION
317
319
  "Frontend" => <<~SECTION
318
320
  # ── Frontend Framework Detection ─────────────────────────────────
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ # One answer to "where does this app keep its concerns", for every surface
5
+ # that lists or counts them.
6
+ #
7
+ # `GetConcern` hardcoded two directories and `ActiveSupportIntrospector`
8
+ # hardcoded five, so a single run answered 80 concerns and 81 concerns for
9
+ # the same app, and the mailer concern only the second one found could not be
10
+ # reached through the first at all. Neither list matches Rails, which
11
+ # autoloads these paths by glob - `Rails::Engine::Configuration#paths` adds
12
+ # `app` with `glob: "{*,*/concerns}"` - so any hardcoded list is one entry
13
+ # behind an app that keeps `app/serializers/concerns`.
14
+ module ConcernPaths
15
+ module_function
16
+
17
+ # @param root [String] application root
18
+ # @return [Array<String>] absolute concern directories that exist
19
+ def resolve(root)
20
+ # An app that names its concern directories means those and no others -
21
+ # the setting has to be able to narrow, or it only ever adds noise. It is
22
+ # unset by default, which is what asks for discovery.
23
+ configured = RailsAiContext.configuration.concern_paths
24
+ dirs =
25
+ if configured.nil?
26
+ Dir.glob(File.join(root, "app", "*", "concerns"))
27
+ else
28
+ # A path that is already absolute is taken as given; `File.join`
29
+ # would graft it onto the root and point at nothing.
30
+ Array(configured).map { |rel| File.absolute_path?(rel) ? rel : File.join(root, rel) }
31
+ end
32
+
33
+ dirs.uniq.select { |dir| Dir.exist?(dir) }.sort
34
+ end
35
+
36
+ # The owner segment names the type: `app/mailers/concerns` holds mailer
37
+ # concerns. Singular so a filter reads `type: "mailer"`.
38
+ def type_for(dir)
39
+ segment = dir[%r{/app/([^/]+)/concerns/?\z}, 1]
40
+ segment ? segment.singularize : "other"
41
+ end
42
+
43
+ # Source file for a concern named by its constant, or nil.
44
+ #
45
+ # @param root [String] application root
46
+ # @param concern_name [String] constant name, e.g. "BulkMailSettingsConcern"
47
+ def find_file(root, concern_name)
48
+ underscore = concern_name.to_s.underscore
49
+ return nil if underscore.empty? || underscore.include?("..")
50
+
51
+ resolve(root)
52
+ .map { |dir| File.join(dir, "#{underscore}.rb") }
53
+ .find { |path| File.exist?(path) }
54
+ end
55
+ end
56
+ end
@@ -230,7 +230,7 @@ module RailsAiContext
230
230
 
231
231
  # Search and file discovery
232
232
  attr_accessor :search_extensions # File extensions for Ruby fallback search (default: rb,js,erb,yml,yaml,json)
233
- attr_accessor :concern_paths # Where to look for concern source files (default: app/models/concerns)
233
+ attr_accessor :concern_paths # Where to look for concern source files (default: nil, discovers app/*/concerns)
234
234
 
235
235
  # Frontend framework detection (optional overrides - auto-detected if nil)
236
236
  attr_accessor :frontend_paths # User-declared frontend dirs (e.g. ["app/frontend", "../web-client"])
@@ -307,7 +307,7 @@ module RailsAiContext
307
307
  @ai_tools = nil
308
308
  @tool_mode = :mcp
309
309
  @search_extensions = %w[rb js erb yml yaml json ts tsx vue svelte haml slim]
310
- @concern_paths = %w[app/models/concerns app/controllers/concerns]
310
+ @concern_paths = nil
311
311
  @frontend_paths = nil
312
312
  @extra_app_paths = []
313
313
  @query_timeout = 5
@@ -55,19 +55,10 @@ module RailsAiContext
55
55
  app.root.to_s
56
56
  end
57
57
 
58
- CONCERN_DIRS = %w[
59
- app/models/concerns
60
- app/controllers/concerns
61
- app/jobs/concerns
62
- app/mailers/concerns
63
- app/channels/concerns
64
- ].freeze
65
-
66
58
  def extract_concerns
67
59
  result = {}
68
- CONCERN_DIRS.each do |rel_dir|
69
- dir = File.join(root, rel_dir)
70
- next unless Dir.exist?(dir)
60
+ ConcernPaths.resolve(root).each do |dir|
61
+ rel_dir = dir.sub("#{root}/", "")
71
62
 
72
63
  modules = Dir.glob(File.join(dir, "**/*.rb")).sort.filter_map do |path|
73
64
  content = RailsAiContext::SafeFile.read(path) or next
@@ -132,8 +132,13 @@ module RailsAiContext
132
132
  # Pundit
133
133
  policies_dir = File.join(root, "app/policies")
134
134
  if Dir.exist?(policies_dir)
135
+ # Named from the path relative to app/policies, not the basename:
136
+ # Zeitwerk makes admin/collection_policy.rb `Admin::CollectionPolicy`,
137
+ # and demodulizing it collided with the top-level policy of the same
138
+ # base name, so one name was listed twice and the other class could
139
+ # not be reached from the context at all.
135
140
  policies = Dir.glob(File.join(policies_dir, "**/*.rb")).map do |f|
136
- File.basename(f, ".rb").camelize
141
+ f.sub("#{policies_dir}/", "").delete_suffix(".rb").camelize
137
142
  end.sort
138
143
  authz[:pundit] = policies if policies.any?
139
144
  end
@@ -164,18 +164,76 @@ module RailsAiContext
164
164
  end
165
165
 
166
166
  # Prefer source-based parsing for actions - always reflects current file state.
167
- # Falls back to reflection for controllers without readable source files.
167
+ #
168
+ # `action_methods` used to answer for a controller whose own file defines
169
+ # no public method, and it cannot: it subtracts inherited methods only as
170
+ # far as the nearest abstract ancestor, which is ActionController::Base.
171
+ # Everything ApplicationController and its concerns define publicly came
172
+ # back as an action, so a two-route controller reported 19 of them, most
173
+ # named things like `set_locale` and `pundit_user`.
174
+ #
175
+ # A thin subclass does still serve actions - its parent defines them - so
176
+ # the answer comes from the parent's source instead.
168
177
  def extract_actions(ctrl, source = nil)
169
- if source
170
- actions = extract_actions_from_source(source)
171
- return actions if actions.any?
172
- end
173
- ctrl.action_methods.to_a.sort
178
+ own = extract_actions_from_source(source) if source
179
+ return own if own&.any?
180
+
181
+ inherited, unreadable_ancestor = inherited_actions(ctrl)
182
+ return inherited if inherited.any?
183
+
184
+ # An ancestor exists that this app does not own the source of, so what
185
+ # it defines is invisible here. `rails g devise:controllers` writes
186
+ # exactly this: the app owns the file, every action in it is commented
187
+ # out, and the gem class supplies them. Reflection brings inherited
188
+ # helpers along, which is worse than the alternative only if the
189
+ # alternative is not claiming the controller serves nothing.
190
+ return ctrl.action_methods.to_a.sort if unreadable_ancestor || source.nil?
191
+
192
+ # Every ancestor was readable and none defines an action. That is an
193
+ # answer, and reflection would only overwrite it with helpers.
194
+ []
174
195
  rescue => e
175
196
  $stderr.puts "[rails-ai-context] extract_actions failed: #{e.message}" if ENV["DEBUG"]
176
197
  []
177
198
  end
178
199
 
200
+ # The nearest ancestor in the app that defines actions of its own.
201
+ #
202
+ # The walk stops at ApplicationController by convention: it is where an
203
+ # app puts the helpers every controller shares, not actions, and reading
204
+ # it is what produced the leak above. A base class that only sets up
205
+ # filters contributes nothing and the walk continues past it.
206
+ #
207
+ # Returns the actions and whether the walk passed an ancestor whose
208
+ # source it could not read, which is what tells an empty answer apart
209
+ # from one this app cannot see.
210
+ def inherited_actions(ctrl)
211
+ unreadable = false
212
+ klass = ctrl.superclass
213
+ while klass&.name && !framework_controller?(klass) && !app_base_controller?(klass)
214
+ src = read_source(klass)
215
+ if src
216
+ actions = extract_actions_from_source(src)
217
+ return [ actions, unreadable ] if actions.any?
218
+ else
219
+ unreadable = true
220
+ end
221
+ klass = klass.superclass
222
+ end
223
+ [ [], unreadable ]
224
+ end
225
+
226
+ def framework_controller?(klass)
227
+ return true if klass.name.start_with?("ActionController::", "AbstractController::")
228
+ return true if klass == ActionController::Base
229
+ return true if defined?(ActionController::API) && klass == ActionController::API
230
+ false
231
+ end
232
+
233
+ def app_base_controller?(klass)
234
+ klass.name == "ApplicationController" || klass.name.end_with?("::ApplicationController")
235
+ end
236
+
179
237
  def extract_actions_from_source(source)
180
238
  ast_result = SourceIntrospector.walk_source(source, {
181
239
  methods: Listeners::MethodsListener
@@ -236,11 +294,7 @@ module RailsAiContext
236
294
  def collect_source_constraints(ctrl, current_source = nil)
237
295
  constraints = {}
238
296
  klass = ctrl
239
- while klass && klass.name
240
- break if klass.name.start_with?("ActionController::", "AbstractController::")
241
- break if klass == ActionController::Base
242
- break if defined?(ActionController::API) && klass == ActionController::API
243
-
297
+ while klass&.name && !framework_controller?(klass)
244
298
  src = (klass == ctrl) ? (current_source || read_source(klass)) : read_source(klass)
245
299
  if src
246
300
  extract_filters_from_source(src).each do |sf|
@@ -45,10 +45,17 @@ module RailsAiContext
45
45
  method: "fetch",
46
46
  key: key,
47
47
  has_default: args.size > 1 || !node.block.nil?,
48
+ # nil unless the fallback is a value that can be printed as one.
49
+ # `ENV.fetch("PORT", defaults[:port])` has a default, but naming
50
+ # it `defaults[:port]` puts a Ruby expression where a reader
51
+ # expects something to copy into a .env file.
52
+ default: literal_value(args[1]),
48
53
  location: node.location.start_line
49
54
  }
50
55
  end
51
56
 
57
+ # An interpolated name has no value at parse time, so it is not a
58
+ # variable name and this returns nil for it.
52
59
  def string_value(node)
53
60
  case node
54
61
  when Prism::StringNode then node.unescaped
@@ -56,6 +63,18 @@ module RailsAiContext
56
63
  else nil
57
64
  end
58
65
  end
66
+
67
+ def literal_value(node)
68
+ case node
69
+ when Prism::StringNode then node.unescaped
70
+ when Prism::SymbolNode then ":#{node.value}"
71
+ when Prism::IntegerNode, Prism::FloatNode then node.value.to_s
72
+ when Prism::TrueNode then "true"
73
+ when Prism::FalseNode then "false"
74
+ when Prism::NilNode then "nil"
75
+ else nil
76
+ end
77
+ end
59
78
  end
60
79
  end
61
80
  end
@@ -14,27 +14,32 @@ module RailsAiContext
14
14
  @in_singleton_class = false
15
15
  @singleton_depth = 0
16
16
  @inline_visibility_stack = [ {} ] # stack of { method_name => visibility }
17
+ @owner_stack = []
17
18
  end
18
19
 
19
20
  # Reset visibility when entering a new class/module scope
20
21
  def on_class_node_enter(node)
21
22
  @visibility_stack.push(:public)
22
23
  @inline_visibility_stack.push({})
24
+ @owner_stack.push(constant_path_string(node.constant_path))
23
25
  end
24
26
 
25
27
  def on_class_node_leave(node)
26
28
  @visibility_stack.pop
27
29
  @inline_visibility_stack.pop
30
+ @owner_stack.pop
28
31
  end
29
32
 
30
33
  def on_module_node_enter(node)
31
34
  @visibility_stack.push(:public)
32
35
  @inline_visibility_stack.push({})
36
+ @owner_stack.push(constant_path_string(node.constant_path))
33
37
  end
34
38
 
35
39
  def on_module_node_leave(node)
36
40
  @visibility_stack.pop
37
41
  @inline_visibility_stack.pop
42
+ @owner_stack.pop
38
43
  end
39
44
 
40
45
  # Track `class << self` blocks
@@ -92,6 +97,13 @@ module RailsAiContext
92
97
  scope: is_class_method ? :class : :instance,
93
98
  visibility: visibility,
94
99
  params: params,
100
+ # Enclosing class/module names, outermost first. A caller that
101
+ # wants one class's own methods needs this: a helper class nested
102
+ # inside a service is a separate owner, not part of its interface.
103
+ owner: @owner_stack.dup,
104
+ # Sliced off the node so defaults read as written (`options = {}`);
105
+ # `params` records names only.
106
+ signature: signature_source(node, is_class_method),
95
107
  location: node.location.start_line,
96
108
  end_location: node.location.end_line,
97
109
  confidence: RailsAiContext::Confidence::VERIFIED
@@ -100,6 +112,38 @@ module RailsAiContext
100
112
 
101
113
  private
102
114
 
115
+ # `class << self` members carry no receiver of their own, so they read
116
+ # as the bare name, which is how they are written.
117
+ def signature_source(node, is_class_method)
118
+ prefix = (is_class_method && node.receiver) ? "self." : ""
119
+ params = parameter_slices(node.parameters)
120
+ return "#{prefix}#{node.name}" if params.empty?
121
+
122
+ "#{prefix}#{node.name}(#{params.join(', ')})"
123
+ end
124
+
125
+ # Each parameter is sliced on its own rather than taking the whole list
126
+ # in one piece: a list split over several lines carries its newlines,
127
+ # and a comment written between two parameters would otherwise swallow
128
+ # the ones after it. Slicing keeps defaults as written, which is the
129
+ # reason for reading source here at all.
130
+ def parameter_slices(parameters)
131
+ return [] unless parameters
132
+
133
+ parts = []
134
+ %i[requireds optionals].each do |group|
135
+ next unless parameters.respond_to?(group)
136
+ parameters.public_send(group).each { |p| parts << p.location.slice }
137
+ end
138
+ parts << parameters.rest.location.slice if parameters.respond_to?(:rest) && parameters.rest
139
+ parameters.posts.each { |p| parts << p.location.slice } if parameters.respond_to?(:posts)
140
+ parameters.keywords.each { |p| parts << p.location.slice } if parameters.respond_to?(:keywords)
141
+ parts << parameters.keyword_rest.location.slice if parameters.respond_to?(:keyword_rest) && parameters.keyword_rest
142
+ parts << parameters.block.location.slice if parameters.respond_to?(:block) && parameters.block
143
+
144
+ parts.compact.map { |slice| slice.gsub(/\s+/, " ").strip }
145
+ end
146
+
103
147
  def extract_params(node)
104
148
  parameters = node.parameters
105
149
  return [] unless parameters
@@ -298,13 +298,16 @@ module RailsAiContext
298
298
  nil
299
299
  end
300
300
 
301
+ # Only files named for a test framework are counted. Globbing every .rb
302
+ # counted whatever a project keeps beside its specs - mailer previews,
303
+ # shared contexts, page objects - under a heading that promises tests.
301
304
  def detect_test_count_by_category
302
305
  counts = {}
303
306
  %w[models controllers requests system services integration features helpers views jobs mailers channels].each do |cat|
304
307
  %w[spec test].each do |base|
305
308
  dir = File.join(root, base, cat)
306
309
  next unless Dir.exist?(dir)
307
- count = Dir.glob(File.join(dir, "**/*.rb")).size
310
+ count = Dir.glob(File.join(dir, "**/*_{spec,test}.rb")).size
308
311
  counts[cat] = (counts[cat] || 0) + count if count > 0
309
312
  end
310
313
  end
@@ -244,10 +244,7 @@ module RailsAiContext
244
244
  next if concern_name.include?("::") && !concern_name.start_with?("App")
245
245
  next if %w[Kernel JSON PP Marshal].include?(concern_name)
246
246
 
247
- underscore = concern_name.underscore
248
- concern_path = RailsAiContext.configuration.concern_paths
249
- .map { |dir| rails_app.root.join(dir, "#{underscore}.rb") }
250
- .find { |p| File.exist?(p) }
247
+ concern_path = ConcernPaths.find_file(rails_app.root.to_s, concern_name)
251
248
  next unless concern_path
252
249
  next if File.size(concern_path) > max_size
253
250
 
@@ -16,8 +16,8 @@ module RailsAiContext
16
16
  },
17
17
  type: {
18
18
  type: "string",
19
- enum: %w[model controller all],
20
- description: "Filter by concern type. model: app/models/concerns/. controller: app/controllers/concerns/. all: both (default)."
19
+ enum: %w[model controller mailer job channel helper other all],
20
+ description: "Filter by concern type, named for the directory: model reads app/models/concerns/, mailer reads app/mailers/concerns/. other: a configured directory outside app/*/concerns. all: everything (default)."
21
21
  },
22
22
  detail: {
23
23
  type: "string",
@@ -36,6 +36,10 @@ module RailsAiContext
36
36
 
37
37
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
38
38
 
39
+ # Model and controller concerns lead because that is where most apps keep
40
+ # most of them; anything else follows in discovery order.
41
+ SECTION_ORDER = %w[model controller mailer job channel helper].freeze
42
+
39
43
  def self.call(name: nil, type: "all", detail: "standard", server_context: nil)
40
44
  root = rails_app.root.to_s
41
45
  max_size = RailsAiContext.configuration.max_file_size
@@ -56,27 +60,17 @@ module RailsAiContext
56
60
  end
57
61
 
58
62
  private_class_method def self.resolve_concern_dirs(root, type)
59
- dirs = case type
60
- when "model"
61
- [ File.join(root, "app", "models", "concerns") ]
62
- when "controller"
63
- [ File.join(root, "app", "controllers", "concerns") ]
64
- else
65
- [
66
- File.join(root, "app", "models", "concerns"),
67
- File.join(root, "app", "controllers", "concerns")
68
- ]
69
- end
63
+ dirs = ConcernPaths.resolve(root)
64
+ return dirs if type.nil? || type == "all"
70
65
 
71
- dirs.select { |d| Dir.exist?(d) }
66
+ dirs.select { |dir| ConcernPaths.type_for(dir) == type }
72
67
  end
73
68
 
74
69
  private_class_method def self.searched_dirs(type)
75
- case type
76
- when "model" then %w[app/models/concerns/]
77
- when "controller" then %w[app/controllers/concerns/]
78
- else %w[app/models/concerns/ app/controllers/concerns/]
79
- end
70
+ return [ "app/*/concerns/" ] if type.nil? || type == "all"
71
+ return [ "any configured directory outside app/*/concerns" ] if type == "other"
72
+
73
+ [ "app/#{type.pluralize}/concerns/" ]
80
74
  end
81
75
 
82
76
  private_class_method def self.show_concern(name, concern_dirs, root, max_size, detail = "standard")
@@ -131,7 +125,7 @@ module RailsAiContext
131
125
  next if sensitive_file?(relative_real)
132
126
 
133
127
  file_path = real
134
- concern_type = dir.include?("models") ? "model" : "controller"
128
+ concern_type = ConcernPaths.type_for(dir)
135
129
  break
136
130
  end
137
131
 
@@ -231,15 +225,18 @@ module RailsAiContext
231
225
  lines << "" << "## Included By (#{includers.size})"
232
226
  includers.each { |i| lines << "- #{i}" }
233
227
  else
234
- lines << "" << "_No models or controllers found that include this concern._"
228
+ lines << "" << "_Nothing in #{includer_locations(concern_type)} includes this concern._"
235
229
  end
236
230
 
237
231
  # Cross-reference hints
238
232
  lines << ""
239
- if concern_type == "model"
233
+ case concern_type
234
+ when "model"
240
235
  lines << "_Next: `rails_get_model_details(model:\"ModelName\")` for models using this concern_"
241
- else
236
+ when "controller"
242
237
  lines << "_Next: `rails_get_controllers(controller:\"ControllerName\")` for controllers using this concern_"
238
+ else
239
+ lines << "_Next: `rails_search_code(pattern:\"include #{name.demodulize.camelize}\")` for everything using this concern_"
243
240
  end
244
241
 
245
242
  text_response(lines.join("\n"))
@@ -250,7 +247,7 @@ module RailsAiContext
250
247
  real_root = File.realpath(root).to_s
251
248
 
252
249
  concern_dirs.each do |dir|
253
- concern_type = dir.include?("models") ? "model" : "controller"
250
+ concern_type = ConcernPaths.type_for(dir)
254
251
  real_dir = File.realpath(dir).to_s
255
252
  Dir.glob(File.join(dir, "**", "*.rb")).sort.each do |file_path|
256
253
  # Apply the 5-rule file-reading pattern per CLAUDE.md. Even though
@@ -295,22 +292,19 @@ module RailsAiContext
295
292
  return text_response("No concerns found in #{concern_dirs.map { |d| d.sub("#{root}/", "") }.join(', ')}.")
296
293
  end
297
294
 
298
- model_concerns = all_concerns.select { |c| c[:type] == "model" }
299
- controller_concerns = all_concerns.select { |c| c[:type] == "controller" }
300
-
301
295
  lines = [ "# Concerns (#{all_concerns.size})", "" ]
302
296
 
303
- if model_concerns.any?
304
- lines << "## Model Concerns (#{model_concerns.size})"
305
- model_concerns.each do |c|
306
- lines << "- **#{c[:name]}** - #{count_phrase(c[:method_count], "method")} (`#{c[:path]}`)"
307
- end
308
- lines << ""
309
- end
310
-
311
- if controller_concerns.any?
312
- lines << "## Controller Concerns (#{controller_concerns.size})"
313
- controller_concerns.each do |c|
297
+ # Grouped by whatever types the app actually has. Rendering a fixed
298
+ # pair of sections meant a concern outside them counted toward the
299
+ # total and then appeared nowhere, which is a worse answer than the
300
+ # undercount it replaced.
301
+ # Name breaks the tie: `sort_by` is not stable, so two types outside
302
+ # SECTION_ORDER would otherwise swap places between runs.
303
+ all_concerns.group_by { |c| c[:type] }
304
+ .sort_by { |type, _| [ SECTION_ORDER.index(type) || SECTION_ORDER.size, type ] }
305
+ .each do |type, concerns|
306
+ lines << "## #{type.camelize} Concerns (#{concerns.size})"
307
+ concerns.each do |c|
314
308
  lines << "- **#{c[:name]}** - #{count_phrase(c[:method_count], "method")} (`#{c[:path]}`)"
315
309
  end
316
310
  lines << ""
@@ -461,18 +455,26 @@ module RailsAiContext
461
455
  []
462
456
  end
463
457
 
458
+ # Names the directories find_includers actually searched, so the empty
459
+ # answer says where it looked rather than naming two it may not have.
460
+ private_class_method def self.includer_locations(concern_type)
461
+ return "app/models or app/controllers" if concern_type.nil? || concern_type == "other"
462
+ "app/#{concern_type.pluralize}"
463
+ end
464
+
464
465
  private_class_method def self.find_includers(concern_name, root, concern_type)
465
466
  includers = []
466
467
  search_dirs = []
467
468
 
468
- case concern_type
469
- when "model"
469
+ # The type names the directory that holds the includers: a mailer
470
+ # concern is included by mailers. Only a concern from outside
471
+ # app/*/concerns has no directory to name, so that one searches both
472
+ # of the places a concern is usually included from.
473
+ if concern_type.nil? || concern_type == "other"
470
474
  search_dirs << File.join(root, "app", "models")
471
- when "controller"
472
475
  search_dirs << File.join(root, "app", "controllers")
473
476
  else
474
- search_dirs << File.join(root, "app", "models")
475
- search_dirs << File.join(root, "app", "controllers")
477
+ search_dirs << File.join(root, "app", concern_type.pluralize)
476
478
  end
477
479
 
478
480
  max_size = RailsAiContext.configuration.max_file_size
@@ -264,25 +264,7 @@ module RailsAiContext
264
264
  next unless source
265
265
  next unless source.include?("ENV")
266
266
 
267
- vars = []
268
- source.each_line.with_index(1) do |line, line_num|
269
- # A commented-out ENV mention is documentation, not usage.
270
- next if line.lstrip.start_with?("#")
271
-
272
- # ENV["VAR_NAME"] or ENV['VAR_NAME']
273
- line.scan(/ENV\[["']([^"']+)["']\]/).each do |match|
274
- vars << { name: match[0], line: line_num }
275
- end
276
-
277
- # ENV.fetch("VAR_NAME") or ENV.fetch("VAR_NAME", default)
278
- line.scan(/ENV\.fetch\(["']([^"']+)["'](?:\s*,\s*([^)]+))?\)/).each do |match|
279
- default = match[1]&.strip
280
- # Sanitize default - don't expose potential secrets
281
- default = sanitize_default(default) if default
282
- vars << { name: match[0], line: line_num, default: default }
283
- end
284
- end
285
-
267
+ vars = env_references(source)
286
268
  env_vars[file] = vars if vars.any?
287
269
  end
288
270
  end
@@ -290,6 +272,35 @@ module RailsAiContext
290
272
  env_vars
291
273
  end
292
274
 
275
+ # The parser decides what counts as a name. A line scan matched whatever
276
+ # sat between the quotes, so an app that builds its variable names by
277
+ # interpolation - the normal shape for one carrying several Redis
278
+ # connections - had `#{prefix}URL` reported as a variable, in the very
279
+ # tool someone reaches for when writing a .env.example. Comments come out
280
+ # for free, including one trailing a line that also reads ENV.
281
+ private_class_method def self.env_references(source)
282
+ ast = Introspectors::SourceIntrospector.walk_source(
283
+ source, { env: -> { Introspectors::Listeners::EnvAccessListener.new } }
284
+ )
285
+
286
+ (ast[:env] || []).filter_map do |entry|
287
+ name = entry[:key]
288
+ # The parser already guaranteed a literal, so this only has to reject
289
+ # what is not a variable name at all. Deliberately looser than
290
+ # EnvIntrospector's uppercase rule: that one filters a catalogue of
291
+ # known Rails variables, while this lists whatever the app reads, and
292
+ # a lowercase `ENV["port"]` is still a variable the app reads.
293
+ next unless name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
294
+
295
+ var = { name: name, line: entry[:location] }
296
+ var[:default] = sanitize_default(entry[:default]) if entry[:default]
297
+ var
298
+ end
299
+ rescue => e
300
+ $stderr.puts "[rails-ai-context] env_references failed: #{e.message}" if ENV["DEBUG"]
301
+ []
302
+ end
303
+
293
304
  private_class_method def self.scan_env_example(root)
294
305
  # Only read .env.example or .env.sample - NEVER .env or .env.local
295
306
  candidates = %w[.env.example .env.sample .env.template]
@@ -477,11 +477,7 @@ module RailsAiContext
477
477
  # Extract public method names from a concern's source file
478
478
  private_class_method def self.extract_concern_methods(concern_name)
479
479
  max_size = RailsAiContext.configuration.max_file_size
480
- underscore = concern_name.underscore
481
- # Search configurable concern paths
482
- path = RailsAiContext.configuration.concern_paths
483
- .map { |dir| rails_app.root.join(dir, "#{underscore}.rb") }
484
- .find { |p| File.exist?(p) }
480
+ path = ConcernPaths.find_file(rails_app.root.to_s, concern_name)
485
481
  return nil unless path
486
482
  return nil if File.size(path) > max_size
487
483
 
@@ -86,7 +86,7 @@ module RailsAiContext
86
86
  lines << "**Initialize:** `#{init_params}`" if init_params
87
87
 
88
88
  # Public methods
89
- public_methods = extract_public_methods(source)
89
+ public_methods = extract_public_methods(source, constant_for(file, services_dir))
90
90
  if public_methods.any?
91
91
  lines << "" << "## Public Methods"
92
92
  public_methods.each { |m| lines << "- `#{m}`" }
@@ -139,7 +139,7 @@ module RailsAiContext
139
139
  relative = file.sub("#{root}/", "")
140
140
  class_name = extract_class_name(source) || File.basename(file, ".rb").camelize
141
141
  line_count = source.lines.size
142
- public_methods = extract_public_methods(source)
142
+ public_methods = extract_public_methods(source, constant_for(file, services_dir))
143
143
 
144
144
  pattern_stats[:total] += 1
145
145
  has_initialize = source.match?(/def initialize/)
@@ -204,6 +204,13 @@ module RailsAiContext
204
204
  text_response(lines.join("\n"))
205
205
  end
206
206
 
207
+ # Zeitwerk requires the constant to match the path, and only the path
208
+ # carries the namespace: `admin/suspend_service.rb` is `Admin::SuspendService`,
209
+ # which no single `class` line in the file spells out.
210
+ private_class_method def self.constant_for(file, services_dir)
211
+ file.sub("#{services_dir}/", "").delete_suffix(".rb").camelize
212
+ end
213
+
207
214
  private_class_method def self.extract_class_name(source)
208
215
  match = source.match(/class\s+([\w:]+)/)
209
216
  match[1] if match
@@ -215,23 +222,39 @@ module RailsAiContext
215
222
  "initialize(#{match[1].strip})"
216
223
  end
217
224
 
218
- private_class_method def self.extract_public_methods(source)
219
- methods = []
220
- in_private = false
221
-
222
- source.each_line do |line|
223
- in_private = true if line.match?(/\A\s*(private|protected)\s*$/)
224
- in_private = false if line.match?(/\A\s*public\s*$/)
225
- next if in_private
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
230
+ # `call` that followed the nested class's `end`.
231
+ #
232
+ # `expected_constant` comes from the path rather than the source, the way
233
+ # JobIntrospector names its classes - Zeitwerk requires the two to agree,
234
+ # and only the path carries the namespace. A file that does not follow
235
+ # the convention falls back to the shallowest nesting, which is the
236
+ # outermost definition in the file.
237
+ private_class_method def self.extract_public_methods(source, expected_constant = nil)
238
+ ast = Introspectors::SourceIntrospector.walk_source(
239
+ source, { methods: Introspectors::Listeners::MethodsListener }
240
+ )
241
+ methods = ast[:methods] || []
242
+ return [] if methods.empty?
243
+
244
+ owner = primary_owner(methods, expected_constant)
245
+
246
+ methods.select { |m| m[:visibility] == :public && m[:owner].join("::") == owner }
247
+ .map { |m| m[:signature] }
248
+ rescue => e
249
+ $stderr.puts "[rails-ai-context] extract_public_methods AST failed: #{e.message}" if ENV["DEBUG"]
250
+ []
251
+ end
226
252
 
227
- if (match = line.match(/\A\s*def\s+((?:self\.)?[\w?!]+(?:\([^)]*\))?)/))
228
- sig = match[1]
229
- next if sig.start_with?("initialize")
230
- methods << sig
231
- end
232
- end
253
+ private_class_method def self.primary_owner(methods, expected_constant)
254
+ owners = methods.map { |m| m[:owner].join("::") }
255
+ return expected_constant if expected_constant && owners.include?(expected_constant)
233
256
 
234
- methods
257
+ owners.min_by { |o| [ o.count(":"), o.length ] }
235
258
  end
236
259
 
237
260
  private_class_method def self.extract_dependencies(source, own_class_name)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.20.3"
4
+ VERSION = "5.21.0"
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: 5.20.3
4
+ version: 5.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine
@@ -279,6 +279,7 @@ files:
279
279
  - lib/rails_ai_context/cli.rb
280
280
  - lib/rails_ai_context/cli/tool_runner.rb
281
281
  - lib/rails_ai_context/code_reloader.rb
282
+ - lib/rails_ai_context/concern_paths.rb
282
283
  - lib/rails_ai_context/confidence.rb
283
284
  - lib/rails_ai_context/configuration.rb
284
285
  - lib/rails_ai_context/count_phrase.rb