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 +4 -4
- data/CHANGELOG.md +65 -0
- data/docs/CONFIGURATION.md +1 -1
- data/docs/GUIDE.md +5 -3
- data/lib/generators/rails_ai_context/install/install_generator.rb +4 -2
- data/lib/rails_ai_context/concern_paths.rb +56 -0
- data/lib/rails_ai_context/configuration.rb +2 -2
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +2 -11
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +6 -1
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +65 -11
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +19 -0
- data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +44 -0
- data/lib/rails_ai_context/introspectors/test_introspector.rb +4 -1
- data/lib/rails_ai_context/tools/get_callbacks.rb +1 -4
- data/lib/rails_ai_context/tools/get_concern.rb +45 -43
- data/lib/rails_ai_context/tools/get_env.rb +30 -19
- data/lib/rails_ai_context/tools/get_model_details.rb +1 -5
- data/lib/rails_ai_context/tools/get_service_pattern.rb +40 -17
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e2ccd7eb081bc5f6f34e998978e38d7d9d5282d097818224c10bd56ff4d30c75
|
|
4
|
+
data.tar.gz: 0bb8a8ad8f57a11a04a41dd2ab9e30f57f25b08382d1b5edca67af0c3db68f38
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/docs/CONFIGURATION.md
CHANGED
|
@@ -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 | `
|
|
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
|
-
#
|
|
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 | `
|
|
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
|
-
#
|
|
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
|
|
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 =
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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, "
|
|
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
|
-
|
|
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
|
|
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 =
|
|
60
|
-
|
|
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 { |
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 =
|
|
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 << "" << "
|
|
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
|
-
|
|
233
|
+
case concern_type
|
|
234
|
+
when "model"
|
|
240
235
|
lines << "_Next: `rails_get_model_details(model:\"ModelName\")` for models using this concern_"
|
|
241
|
-
|
|
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 =
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
lines << "##
|
|
313
|
-
|
|
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
|
-
|
|
469
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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)
|
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.
|
|
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
|