rails-ai-context 5.17.0 → 5.18.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 +42 -0
- data/CONTRIBUTING.md +6 -4
- data/docs/ARCHITECTURE.md +1 -1
- data/docs/INTROSPECTORS.md +46 -1
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -0
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +2 -0
- data/lib/rails_ai_context/introspectors/api_introspector.rb +1 -11
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +31 -15
- data/lib/rails_ai_context/introspectors/autoload_introspector.rb +18 -9
- data/lib/rails_ai_context/introspectors/component_introspector.rb +37 -61
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +50 -20
- data/lib/rails_ai_context/introspectors/initializer_introspector.rb +9 -4
- data/lib/rails_ai_context/introspectors/job_introspector.rb +21 -14
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +42 -4
- data/lib/rails_ai_context/introspectors/listeners/chained_call_listener.rb +19 -1
- data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +32 -0
- data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +118 -0
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +97 -0
- data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +8 -5
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -0
- data/lib/rails_ai_context/introspectors/model_introspector.rb +1 -5
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +25 -21
- data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +3 -16
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +5 -10
- data/lib/rails_ai_context/introspectors/source_introspector.rb +2 -0
- data/lib/rails_ai_context/introspectors/test_introspector.rb +10 -7
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +11 -6
- data/lib/rails_ai_context/introspectors/view_introspector.rb +3 -0
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +5 -0
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d4a8615479dc45f376da0873dbd18511feda7dafddad0e4c4c14039d26f8a4bf
|
|
4
|
+
data.tar.gz: 9fa06c53d871f05d30c284964777ebd15b26d44e87284e9232591e288ab47d29
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 38cc97cb5ec47c4ecd680ebb588169372d4d5a41c53dd0c59fde9260dc2bffad4f9830ffea5bc84cbef0079b167d522bccf77903104e5f6e5a03bd93aa85445d
|
|
7
|
+
data.tar.gz: 9987140ad897944e8def9318c6eedad861228f345465937062dba5e6ea36a2c47246d5c0697acce041afe7966b866d40bd289e67eeb1d582a6b220cc5c6dc147
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,48 @@ 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.18.0] - 2026-08-09
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Three new Prism listeners.** `ConfigAssignmentListener` reads
|
|
13
|
+
`config.key = value` and `config.key.subkey = value` in initializers, matching
|
|
14
|
+
the root anywhere in the chain so `Rails.application.config.assets.paths`
|
|
15
|
+
resolves too. `ComponentStructureListener` reads ViewComponent and Phlex
|
|
16
|
+
structure: `renders_one`/`renders_many`, slot methods, constant tables,
|
|
17
|
+
`case @ivar` variant branching, and `CONST[@ivar]` indexing.
|
|
18
|
+
`ClassDefinitionListener` reads class definitions with their superclass.
|
|
19
|
+
- **`docs/INTROSPECTORS.md` documents the listener catalogue**, how to add a
|
|
20
|
+
listener, and when regex is the right tool instead of the AST.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **Auth, component, channel, controller, inflection and initializer reading
|
|
25
|
+
moved from regex to the AST.** Devise and Doorkeeper settings, devise-jwt
|
|
26
|
+
detection, ViewComponent and Phlex structure, Action Cable `identified_by` /
|
|
27
|
+
`stream_from` / `stream_for` / `periodically`, `rate_limit` options, custom
|
|
28
|
+
inflections, CORS origins, RSpec helper `include`s, `DatabaseCleaner.strategy`
|
|
29
|
+
and model class detection are all read structurally now. Formatting that used
|
|
30
|
+
to defeat the patterns (multi-line arguments, adjacent string literals,
|
|
31
|
+
`%i[]` and `%w[]` forms) is read correctly.
|
|
32
|
+
- **A filter's `if:` condition reports the action it names.** A lambda has no
|
|
33
|
+
literal value, so `if: -> { action_name == "create" }` used to surface as
|
|
34
|
+
`[INFERRED]`; it now reads `action_name == "create"`. Same key, same type.
|
|
35
|
+
- **`rate_limit_parsed[:within]` no longer keeps a trailing comma.**
|
|
36
|
+
`within: 1.minute, only: :create` returned `"1.minute,"` and now returns
|
|
37
|
+
`"1.minute"`.
|
|
38
|
+
- **Initializer `setup_calls` sees more.** The old pattern only matched a line
|
|
39
|
+
beginning with `config.`, so `Rails.application.config.x = y` and multi-line
|
|
40
|
+
chains were missed.
|
|
41
|
+
- **Every remaining regex over Ruby source carries a note** saying why regex is
|
|
42
|
+
right there: non-Ruby files, mixed-extension globs, vocabulary matching, or
|
|
43
|
+
scope the listeners cannot see.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- **Dead namespace-tracking loop removed from `RakeTaskIntrospector`**, which
|
|
48
|
+
walked every line of every `.rake` file and discarded the result.
|
|
49
|
+
|
|
8
50
|
## [5.17.0] - 2026-08-09
|
|
9
51
|
|
|
10
52
|
### Added
|
data/CONTRIBUTING.md
CHANGED
|
@@ -51,11 +51,13 @@ Listeners extract specific concerns (associations, validations, etc.) from the A
|
|
|
51
51
|
1. Create `lib/rails_ai_context/introspectors/listeners/your_listener.rb` inheriting from `BaseListener`
|
|
52
52
|
2. Implement `on_call_node_enter(node)` and/or `on_def_node_enter(node)` - only the events your concern needs
|
|
53
53
|
3. Use `confidence_for(node)` from `BaseListener` to tag results `[VERIFIED]` or `[INFERRED]`
|
|
54
|
-
4. Store results in `@results` (accessed via `#results`)
|
|
55
|
-
5.
|
|
56
|
-
6.
|
|
54
|
+
4. Store results in `@results` (accessed via `#results`) as plain hashes, never Prism nodes
|
|
55
|
+
5. If your listener needs an event `SourceIntrospector.register_listener` doesn't already wire up, add it there
|
|
56
|
+
6. Register the key/class pair in `SourceIntrospector::LISTENER_MAP` only if the listener should run on every model walk; listeners used by one introspector are passed to `SourceIntrospector.walk(path, key => Listener)` at the call site instead
|
|
57
|
+
7. Write specs in `spec/lib/rails_ai_context/introspectors/listeners/your_listener_spec.rb`
|
|
58
|
+
8. Add a row to the listener catalogue in `docs/INTROSPECTORS.md`
|
|
57
59
|
|
|
58
|
-
See existing listeners in `lib/rails_ai_context/introspectors/listeners/` for reference patterns.
|
|
60
|
+
See existing listeners in `lib/rails_ai_context/introspectors/listeners/` for reference patterns, and `docs/INTROSPECTORS.md` for the catalogue and the AST-vs-regex rule.
|
|
59
61
|
|
|
60
62
|
## Adding a CLI Tool Interface
|
|
61
63
|
|
data/docs/ARCHITECTURE.md
CHANGED
|
@@ -26,7 +26,7 @@ graph TD
|
|
|
26
26
|
subgraph engine["Introspection Engine"]
|
|
27
27
|
direction LR
|
|
28
28
|
I["Introspectors\n39 modules\nPresets\nCached"]
|
|
29
|
-
AST["AST Engine\nPrism\
|
|
29
|
+
AST["AST Engine\nPrism\n24 listeners\nConfidence tags"]
|
|
30
30
|
H["Hydration Layer\nSchema hints\ninjected into\ntool responses"]
|
|
31
31
|
end
|
|
32
32
|
|
data/docs/INTROSPECTORS.md
CHANGED
|
@@ -171,7 +171,7 @@ These introspectors map directly onto [`RAILS_NERVOUS_SYSTEM.md`](../RAILS_NERVO
|
|
|
171
171
|
|
|
172
172
|
The **SourceIntrospector** uses Prism AST parsing for model analysis. It is infrastructure shared by the introspectors above rather than an introspector you can enable: there is no `:source` key for `config.introspectors`.
|
|
173
173
|
|
|
174
|
-
It runs a single-pass Dispatcher that walks the AST once and feeds events to all registered listeners simultaneously. Model analysis uses the seven below by default; the
|
|
174
|
+
It runs a single-pass Dispatcher that walks the AST once and feeds events to all registered listeners simultaneously. Model analysis uses the seven below by default; the rest are used through targeted walks (schema dumps, migrations, Gemfiles, rake tasks, initializers, components, and so on).
|
|
175
175
|
|
|
176
176
|
### The 7 default Prism listeners
|
|
177
177
|
|
|
@@ -185,6 +185,51 @@ It runs a single-pass Dispatcher that walks the AST once and feeds events to all
|
|
|
185
185
|
| MacrosListener | `encrypts`, `normalizes`, `delegate`, `has_secure_password`, `serialize`, `store`, `has_one_attached`, `has_many_attached`, `has_rich_text`, `generates_token_for`, `attribute` |
|
|
186
186
|
| MethodsListener | `def`/`def self.`, visibility tracking, parameter extraction, `class << self` |
|
|
187
187
|
|
|
188
|
+
### The targeted-walk listeners
|
|
189
|
+
|
|
190
|
+
Passed to `SourceIntrospector.walk(path, key => Listener)` when a specific file needs reading. Several take arguments, so one class serves many callers.
|
|
191
|
+
|
|
192
|
+
| Listener | What it detects |
|
|
193
|
+
|:---------|:---------------|
|
|
194
|
+
| GenericMacroListener | Any receiver-less macro you name: `GenericMacroListener.new(:devise, :rate_limit)`. Returns args, values (with a source-slice fallback), options, option values and option nodes |
|
|
195
|
+
| ChainedCallListener | Calls on a receiver: `ChainedCallListener.new(:includes)`, or `receiver: :inflect` to pin the receiver. Reports the receiver name |
|
|
196
|
+
| ConfigAssignmentListener | `config.key = value` and `config.a.b = value` in initializers, plus bare `config.jwt do ... end` section references. Takes a root name (`:config` by default, e.g. `:DatabaseCleaner`) |
|
|
197
|
+
| ClassDefinitionListener | Class definitions with their superclass, namespaces resolved |
|
|
198
|
+
| ComponentStructureListener | ViewComponent and Phlex structure: `renders_one`/`renders_many`, slot methods, hash/array constant tables, `case @ivar` variant branching, `CONST[@ivar]` indexing |
|
|
199
|
+
| MiddlewareConfigListener | `config.middleware.use` / `insert_before` / `insert_after` |
|
|
200
|
+
| SchemaDslListener | `schema.rb`: `create_table`, `t.string`, `t.index`, `add_foreign_key`, `create_enum` |
|
|
201
|
+
| MigrationDslListener | Migration DSL: `create_table`, `add_column`, `add_index`, `add_reference`, and friends |
|
|
202
|
+
| RoutesDslListener | `config/routes.rb`, resolving namespace/scope/resources nesting into flat routes |
|
|
203
|
+
| MountListener | `mount Sidekiq::Web, at: "/sidekiq"` and the hash form |
|
|
204
|
+
| GemfileDslListener | `gem "name", "version"` and `group :development do ... end` |
|
|
205
|
+
| RakeTaskDslListener | `namespace`, `desc`, `task` in `.rake` files |
|
|
206
|
+
| EnvAccessListener | `ENV["KEY"]`, `ENV.fetch("KEY")`, `ENV.fetch("KEY", default)` |
|
|
207
|
+
| MongoidFieldsListener | Mongoid `field`, embedded relations, custom collection names |
|
|
208
|
+
| MailboxRoutingListener | Action Mailbox `routing` and processing callbacks |
|
|
209
|
+
| ModelReferenceListener | Model constants used in controllers: `Post.find`, `params.require(:post)`, ivar writes |
|
|
210
|
+
| VariantCallListener | `variant` calls (ChainedCallListener with `:variant` preset) |
|
|
211
|
+
|
|
212
|
+
### Adding a listener
|
|
213
|
+
|
|
214
|
+
1. Subclass `BaseListener` in `lib/rails_ai_context/introspectors/listeners/`. Use its helpers rather than re-reading nodes: `extract_symbol_args`, `extract_keyword_options`, `extract_arg_values` (source-slice fallback for expressions like `2.hours`), `extract_keyword_sources`, `extract_keyword_nodes`, `keyword_hash`, `constant_path_string`.
|
|
215
|
+
2. Implement the `on_*_node_enter` hooks you need and push plain hashes onto `@results`. Never return Prism nodes as the result itself; `option_nodes` is the one deliberate exception, for callers that must inspect an expression's shape.
|
|
216
|
+
3. Register the event in `SourceIntrospector.register_listener` if it is not already in the list (call, def, class, module, block, singleton class, case, constant write).
|
|
217
|
+
4. Add a spec of the same name under `spec/lib/rails_ai_context/introspectors/listeners/`.
|
|
218
|
+
5. Add a row to the table above.
|
|
219
|
+
|
|
220
|
+
### Choosing between AST and regex
|
|
221
|
+
|
|
222
|
+
Use the AST when the thing you want **is** a Ruby construct: a macro call and its arguments, a method definition, a class and its superclass, an assignment, a constant, a `case`. If you find yourself running a regex over text you already parsed, that is a parse of a parse. Fix it at the node.
|
|
223
|
+
|
|
224
|
+
Regex is the right tool, and stays, for:
|
|
225
|
+
|
|
226
|
+
- **Files that are not Ruby.** `Gemfile.lock`, YAML (`database.yml`, `sidekiq.yml`, fixtures), `structure.sql`, Dockerfiles, ERB, HAML, Slim, JavaScript.
|
|
227
|
+
- **Mixed-extension globs.** A view scan spanning ERB and Phlex `.rb` needs one matcher, or the two halves drift apart.
|
|
228
|
+
- **Vocabulary classification.** "Does this middleware body talk about auth?" is about words, not structure; no node carries it.
|
|
229
|
+
- **Anything the listeners cannot scope.** Tying a call to the enclosing action or `namespace` block needs block scope the listeners do not track, so those fall back to line scanning.
|
|
230
|
+
|
|
231
|
+
Every remaining regex over `.rb` content carries a one-line comment saying which of these it is. If you add one without a reason, convert it instead.
|
|
232
|
+
|
|
188
233
|
### Confidence tagging
|
|
189
234
|
|
|
190
235
|
Every AST result carries a confidence tag:
|
|
@@ -112,6 +112,7 @@ module RailsAiContext
|
|
|
112
112
|
next false unless Dir.exist?(dir)
|
|
113
113
|
Dir.glob(File.join(dir, "**/*.{erb,haml,slim,js,ts,jsx,tsx,mjs,rb}")).any? do |f|
|
|
114
114
|
next false if File.directory?(f)
|
|
115
|
+
# The glob spans ERB, JS and Ruby, so one text scan covers them all.
|
|
115
116
|
(RailsAiContext::SafeFile.read(f) || "").match?(/direct.upload|DirectUpload|direct_upload/)
|
|
116
117
|
end
|
|
117
118
|
end
|
|
@@ -102,6 +102,8 @@ module RailsAiContext
|
|
|
102
102
|
# and would produce non-deterministic output on large monorepos.
|
|
103
103
|
Dir.glob(File.join(dir, "**/*.rb")).sort.first(2000).each do |path|
|
|
104
104
|
content = RailsAiContext::SafeFile.read(path) or next
|
|
105
|
+
# Mention of the class anywhere counts, so a text scan over 2000
|
|
106
|
+
# files beats parsing each one. Regex stays.
|
|
105
107
|
next unless content.match?(/MessageEncryptor|MessageVerifier/)
|
|
106
108
|
relative = path.sub("#{root}/", "")
|
|
107
109
|
hits << { file: relative, encryptor: content.include?("MessageEncryptor"), verifier: content.include?("MessageVerifier") }
|
|
@@ -103,17 +103,7 @@ module RailsAiContext
|
|
|
103
103
|
origin_calls = ast_data[:origins]
|
|
104
104
|
return nil if origin_calls.empty?
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
origins = origin_calls.flat_map { |macro| macro[:args].map(&:to_s) }
|
|
108
|
-
# GenericMacroListener extracts symbol args; for string origins we need
|
|
109
|
-
# the raw content fallback
|
|
110
|
-
if origins.empty?
|
|
111
|
-
content = RailsAiContext::SafeFile.read(cors_path)
|
|
112
|
-
return nil unless content
|
|
113
|
-
origins = content.scan(/origins\s+(.+)$/).flatten.flat_map do |line|
|
|
114
|
-
line.scan(/["']([^"']+)["']/).flatten
|
|
115
|
-
end
|
|
116
|
-
end
|
|
106
|
+
origins = origin_calls.flat_map { |macro| macro[:values].flatten.map(&:to_s) }
|
|
117
107
|
|
|
118
108
|
{ file: "config/initializers/cors.rb", origins: origins }
|
|
119
109
|
rescue => e
|
|
@@ -5,6 +5,11 @@ module RailsAiContext
|
|
|
5
5
|
# Discovers authentication and authorization setup: Devise, Rails 8 auth,
|
|
6
6
|
# Pundit, CanCanCan, CORS, CSP.
|
|
7
7
|
class AuthIntrospector
|
|
8
|
+
# Settings reported as a bare word rather than as their literal value,
|
|
9
|
+
# matching how Devise's own docs name the strategies.
|
|
10
|
+
SYMBOL_DEVISE_SETTINGS = %i[lock_strategy unlock_strategy].freeze
|
|
11
|
+
DEVISE_SETTINGS = (%i[timeout_in maximum_attempts password_length] + SYMBOL_DEVISE_SETTINGS).freeze
|
|
12
|
+
|
|
8
13
|
attr_reader :app
|
|
9
14
|
|
|
10
15
|
def initialize(app)
|
|
@@ -192,8 +197,8 @@ module RailsAiContext
|
|
|
192
197
|
|
|
193
198
|
devise_init = File.join(root, "config/initializers/devise.rb")
|
|
194
199
|
if File.exist?(devise_init)
|
|
195
|
-
|
|
196
|
-
return { detected: true, jwt_configured:
|
|
200
|
+
jwt = config_assignments(devise_init).any? { |hit| hit[:path].first == :jwt }
|
|
201
|
+
return { detected: true, jwt_configured: jwt }
|
|
197
202
|
end
|
|
198
203
|
|
|
199
204
|
{ detected: true }
|
|
@@ -208,13 +213,15 @@ module RailsAiContext
|
|
|
208
213
|
doorkeeper_init = File.join(root, "config/initializers/doorkeeper.rb")
|
|
209
214
|
return { detected: true } unless File.exist?(doorkeeper_init)
|
|
210
215
|
|
|
211
|
-
|
|
212
|
-
|
|
216
|
+
ast = SourceIntrospector.walk(doorkeeper_init, {
|
|
217
|
+
settings: -> { Listeners::GenericMacroListener.new(:grant_flows, :access_token_expires_in) }
|
|
218
|
+
})
|
|
213
219
|
|
|
214
|
-
grant_flows =
|
|
215
|
-
grant_flows = grant_flows
|
|
220
|
+
grant_flows = ast[:settings].find { |h| h[:macro] == :grant_flows }
|
|
221
|
+
grant_flows = Array(grant_flows[:values].first).map(&:to_s) if grant_flows
|
|
216
222
|
|
|
217
|
-
expires_in =
|
|
223
|
+
expires_in = ast[:settings].find { |h| h[:macro] == :access_token_expires_in }
|
|
224
|
+
expires_in = expires_in[:values].first&.to_s if expires_in
|
|
218
225
|
|
|
219
226
|
result = { detected: true }
|
|
220
227
|
result[:grant_flows] = grant_flows if grant_flows
|
|
@@ -281,21 +288,30 @@ module RailsAiContext
|
|
|
281
288
|
def extract_devise_settings
|
|
282
289
|
path = File.join(app.root, "config", "initializers", "devise.rb")
|
|
283
290
|
return {} unless File.exist?(path)
|
|
284
|
-
content = RailsAiContext::SafeFile.read(path)
|
|
285
|
-
return {} unless content
|
|
286
291
|
|
|
287
292
|
settings = {}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
293
|
+
config_assignments(path).each do |hit|
|
|
294
|
+
next unless hit[:assignment] && hit[:path].size == 1
|
|
295
|
+
key = hit[:path].first
|
|
296
|
+
next unless DEVISE_SETTINGS.include?(key)
|
|
297
|
+
settings[key] = devise_setting_value(key, hit)
|
|
298
|
+
end
|
|
299
|
+
settings
|
|
294
300
|
rescue => e
|
|
295
301
|
$stderr.puts "[rails-ai-context] extract_devise_settings failed: #{e.message}" if ENV["DEBUG"]
|
|
296
302
|
{}
|
|
297
303
|
end
|
|
298
304
|
|
|
305
|
+
def devise_setting_value(key, hit)
|
|
306
|
+
return hit[:value].to_s if SYMBOL_DEVISE_SETTINGS.include?(key)
|
|
307
|
+
return hit[:value] if key == :maximum_attempts && hit[:value].is_a?(Integer)
|
|
308
|
+
hit[:source]
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def config_assignments(path)
|
|
312
|
+
SourceIntrospector.walk(path, { config: Listeners::ConfigAssignmentListener })[:config]
|
|
313
|
+
end
|
|
314
|
+
|
|
299
315
|
def scan_models_for_devise
|
|
300
316
|
models_dir = File.join(root, "app/models")
|
|
301
317
|
return [] unless Dir.exist?(models_dir)
|
|
@@ -6,6 +6,8 @@ module RailsAiContext
|
|
|
6
6
|
# inflections, autoload/eager-load paths, collapsed dirs, and ignored
|
|
7
7
|
# paths. Covers RAILS_NERVOUS_SYSTEM.md §3 (Autoloading - Zeitwerk).
|
|
8
8
|
class AutoloadIntrospector
|
|
9
|
+
INFLECTION_DIRECTIVES = %i[acronym plural singular irregular uncountable human].freeze
|
|
10
|
+
|
|
9
11
|
attr_reader :app
|
|
10
12
|
|
|
11
13
|
def initialize(app)
|
|
@@ -102,20 +104,21 @@ module RailsAiContext
|
|
|
102
104
|
|
|
103
105
|
inflections = []
|
|
104
106
|
Dir.glob(File.join(dir, "*.rb")).sort.each do |path|
|
|
105
|
-
content = RailsAiContext::SafeFile.read(path) or next
|
|
106
107
|
rel = path.sub("#{root}/", "")
|
|
108
|
+
ast = SourceIntrospector.walk(path, {
|
|
109
|
+
directives: -> { Listeners::ChainedCallListener.new(INFLECTION_DIRECTIVES, receiver: :inflect) },
|
|
110
|
+
chained: -> { Listeners::ChainedCallListener.new(:inflect) },
|
|
111
|
+
bare: -> { Listeners::GenericMacroListener.new(:inflect) }
|
|
112
|
+
})
|
|
107
113
|
|
|
108
114
|
# inflect "api" => "API", "xml" => "XML"
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
inflections.concat(pairs.map { |k, v| { file: rel, rule: "#{k} => #{v}" } })
|
|
115
|
+
(ast[:chained] + ast[:bare]).each do |hit|
|
|
116
|
+
hit[:options].each { |k, v| inflections << { file: rel, rule: "#{k} => #{v}" } }
|
|
112
117
|
end
|
|
113
118
|
|
|
114
|
-
# inflect.acronym "API" / inflect.plural
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
rule = arg2 ? "#{method}: #{arg1} => #{arg2}" : "#{method}: #{arg1}"
|
|
118
|
-
inflections << { file: rel, rule: rule }
|
|
119
|
+
# inflect.acronym "API" / inflect.plural(/…/, "…") / inflect.irregular "…", "…"
|
|
120
|
+
ast[:directives].each do |hit|
|
|
121
|
+
inflections << { file: rel, rule: directive_rule(hit) }
|
|
119
122
|
end
|
|
120
123
|
end
|
|
121
124
|
inflections.uniq
|
|
@@ -124,6 +127,12 @@ module RailsAiContext
|
|
|
124
127
|
[]
|
|
125
128
|
end
|
|
126
129
|
|
|
130
|
+
def directive_rule(hit)
|
|
131
|
+
values = hit[:values].map(&:to_s)
|
|
132
|
+
return "#{hit[:method]}: #{values.first} => #{values.last}" if values.size > 1
|
|
133
|
+
"#{hit[:method]}: #{values.first}"
|
|
134
|
+
end
|
|
135
|
+
|
|
127
136
|
def relativize(paths)
|
|
128
137
|
Array(paths).map do |p|
|
|
129
138
|
s = p.to_s
|
|
@@ -51,16 +51,18 @@ module RailsAiContext
|
|
|
51
51
|
class_name = extract_class_name(content)
|
|
52
52
|
return nil unless class_name
|
|
53
53
|
|
|
54
|
+
structure = extract_structure(content)
|
|
55
|
+
type = detect_component_type(content)
|
|
54
56
|
props = extract_props(content)
|
|
55
|
-
enum_values = extract_enum_values(
|
|
56
|
-
attach_enum_values_to_props(props, enum_values,
|
|
57
|
+
enum_values = extract_enum_values(structure)
|
|
58
|
+
attach_enum_values_to_props(props, enum_values, structure)
|
|
57
59
|
|
|
58
60
|
component = {
|
|
59
61
|
name: class_name,
|
|
60
62
|
file: relative,
|
|
61
|
-
type:
|
|
63
|
+
type: type,
|
|
62
64
|
props: props,
|
|
63
|
-
slots: extract_slots(
|
|
65
|
+
slots: extract_slots(structure, type)
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
preview = find_preview(path, class_name)
|
|
@@ -235,70 +237,43 @@ module RailsAiContext
|
|
|
235
237
|
nil
|
|
236
238
|
end
|
|
237
239
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
+
# Structural facts about the component class, grouped by kind so each
|
|
241
|
+
# consumer reads its own bucket instead of re-filtering the whole list.
|
|
242
|
+
def extract_structure(content)
|
|
243
|
+
results = SourceIntrospector.walk_source(content, {
|
|
244
|
+
structure: Listeners::ComponentStructureListener
|
|
245
|
+
})[:structure]
|
|
240
246
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
slot_macros: -> { Listeners::GenericMacroListener.new(:renders_one, :renders_many) }
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
ast_data[:slot_macros].each do |macro|
|
|
247
|
-
slot_name = macro[:args]&.first&.to_s
|
|
248
|
-
next unless slot_name
|
|
249
|
-
|
|
250
|
-
type = macro[:macro] == :renders_one ? :one : :many
|
|
251
|
-
slot = { name: slot_name, type: type }
|
|
252
|
-
|
|
253
|
-
# Check if there's a renderer argument (second arg or options)
|
|
254
|
-
remaining_args = macro[:args][1..]
|
|
255
|
-
if remaining_args&.any?
|
|
256
|
-
slot[:renderer] = remaining_args.map(&:to_s).join(", ")
|
|
257
|
-
end
|
|
247
|
+
results.group_by { |entry| entry[:kind] }
|
|
248
|
+
end
|
|
258
249
|
|
|
259
|
-
|
|
250
|
+
def extract_slots(structure, type)
|
|
251
|
+
slots = structure.fetch(:slot_macro, []).map do |entry|
|
|
252
|
+
entry.slice(:name, :type, :renderer).compact
|
|
260
253
|
end
|
|
261
254
|
|
|
262
|
-
# Phlex slots
|
|
263
|
-
if
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
slots << { name: name, type: :phlex_slot }
|
|
255
|
+
# Phlex slots are plain methods taking a block.
|
|
256
|
+
if type == :phlex
|
|
257
|
+
structure.fetch(:slot, []).each do |entry|
|
|
258
|
+
slots << { name: entry[:name], type: :phlex_slot }
|
|
267
259
|
end
|
|
268
260
|
end
|
|
269
261
|
|
|
270
262
|
slots
|
|
271
263
|
end
|
|
272
264
|
|
|
273
|
-
#
|
|
274
|
-
#
|
|
275
|
-
|
|
276
|
-
# 1. Hash constants: VARIANTS = { primary: "...", secondary: "..." } -> keys
|
|
277
|
-
# 2. Array constants: SIZES = [:sm, :md, :lg] -> elements
|
|
278
|
-
# 3. Case statements: case @variant; when :primary; when :secondary -> when values
|
|
279
|
-
def extract_enum_values(content)
|
|
265
|
+
# Enumerable values a prop can take, keyed by downcased constant name
|
|
266
|
+
# (VARIANTS -> "variants") or by the instance variable a `case` branches on.
|
|
267
|
+
def extract_enum_values(structure)
|
|
280
268
|
enums = {}
|
|
281
269
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
keys = body.scan(/(\w+):/).map(&:first)
|
|
285
|
-
enums[name.downcase] = keys if keys.any?
|
|
270
|
+
structure.fetch(:constant_table, []).each do |entry|
|
|
271
|
+
enums[entry[:name].downcase] = entry[:values]
|
|
286
272
|
end
|
|
287
273
|
|
|
288
|
-
#
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
enums[name.downcase] = values if values.any?
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
# Pattern 3: Case statements - case @ivar; when :val1 ... when :val2
|
|
295
|
-
# Use a non-greedy match that stops at the next `end`, `case`, or `def` keyword
|
|
296
|
-
content.scan(/case\s+@(\w+)\s*\n(.*?)(?=\n\s*(?:end|case|def)\b)/m) do |ivar, block|
|
|
297
|
-
values = block.scan(/when\s+:(\w+)/).map(&:first)
|
|
298
|
-
next if values.empty?
|
|
299
|
-
# Merge with existing values for same ivar (handles multiple case blocks)
|
|
300
|
-
existing = enums[ivar] || []
|
|
301
|
-
enums[ivar] = (existing + values).uniq
|
|
274
|
+
# Several case blocks can branch on the same ivar.
|
|
275
|
+
structure.fetch(:variant_branch, []).each do |entry|
|
|
276
|
+
enums[entry[:ivar]] = ((enums[entry[:ivar]] || []) + entry[:values]).uniq
|
|
302
277
|
end
|
|
303
278
|
|
|
304
279
|
enums
|
|
@@ -308,7 +283,7 @@ module RailsAiContext
|
|
|
308
283
|
# 1. Direct ivar match: prop "variant" matches case @variant values
|
|
309
284
|
# 2. Constant name match: prop "size" matches SIZES constant, prop "variant" matches VARIANTS constant
|
|
310
285
|
# 3. Constant usage in initialize: @size referenced as SIZES[@size] matches prop "size"
|
|
311
|
-
def attach_enum_values_to_props(props, enum_values,
|
|
286
|
+
def attach_enum_values_to_props(props, enum_values, structure)
|
|
312
287
|
props.each do |prop|
|
|
313
288
|
name = prop[:name]
|
|
314
289
|
values = nil
|
|
@@ -328,13 +303,14 @@ module RailsAiContext
|
|
|
328
303
|
end
|
|
329
304
|
end
|
|
330
305
|
|
|
331
|
-
# Constant usage match:
|
|
306
|
+
# Constant usage match: CONST[@ivar] ties the prop to that table
|
|
332
307
|
unless values
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
308
|
+
structure.fetch(:constant_index, []).each do |entry|
|
|
309
|
+
next unless entry[:ivar] == name
|
|
310
|
+
table = enum_values[entry[:constant].downcase]
|
|
311
|
+
next unless table
|
|
312
|
+
values = table
|
|
313
|
+
break
|
|
338
314
|
end
|
|
339
315
|
end
|
|
340
316
|
|
|
@@ -117,7 +117,7 @@ module RailsAiContext
|
|
|
117
117
|
source = RailsAiContext::SafeFile.read(path)
|
|
118
118
|
return { error: "unreadable" } unless source
|
|
119
119
|
parent = extract_parent_class_ast(source)
|
|
120
|
-
|
|
120
|
+
rate_limit = rate_limit_entry(source)
|
|
121
121
|
details = {
|
|
122
122
|
parent_class: parent,
|
|
123
123
|
api_controller: parent.include?("API"),
|
|
@@ -127,8 +127,8 @@ module RailsAiContext
|
|
|
127
127
|
strong_params: extract_strong_params(source),
|
|
128
128
|
respond_to_formats: extract_respond_to(source),
|
|
129
129
|
rescue_from: extract_rescue_from(source),
|
|
130
|
-
rate_limit:
|
|
131
|
-
rate_limit_parsed: parse_rate_limit(
|
|
130
|
+
rate_limit: extract_rate_limit(source, rate_limit),
|
|
131
|
+
rate_limit_parsed: parse_rate_limit(rate_limit),
|
|
132
132
|
turbo_stream_actions: extract_turbo_stream_actions(source)
|
|
133
133
|
}.compact
|
|
134
134
|
details
|
|
@@ -138,7 +138,7 @@ module RailsAiContext
|
|
|
138
138
|
|
|
139
139
|
def extract_controller_details(ctrl)
|
|
140
140
|
source = read_source(ctrl)
|
|
141
|
-
|
|
141
|
+
rate_limit = rate_limit_entry(source)
|
|
142
142
|
|
|
143
143
|
{
|
|
144
144
|
parent_class: ctrl.superclass.name,
|
|
@@ -149,8 +149,8 @@ module RailsAiContext
|
|
|
149
149
|
strong_params: extract_strong_params(source),
|
|
150
150
|
respond_to_formats: extract_respond_to(source),
|
|
151
151
|
rescue_from: extract_rescue_from(source),
|
|
152
|
-
rate_limit:
|
|
153
|
-
rate_limit_parsed: parse_rate_limit(
|
|
152
|
+
rate_limit: extract_rate_limit(source, rate_limit),
|
|
153
|
+
rate_limit_parsed: parse_rate_limit(rate_limit),
|
|
154
154
|
turbo_stream_actions: extract_turbo_stream_actions(source)
|
|
155
155
|
}.compact
|
|
156
156
|
end
|
|
@@ -279,7 +279,11 @@ module RailsAiContext
|
|
|
279
279
|
filter[:unless] = opts[:unless].to_s
|
|
280
280
|
end
|
|
281
281
|
if opts[:if]
|
|
282
|
-
|
|
282
|
+
# A lambda has no literal value, so `opts[:if]` is "[INFERRED]".
|
|
283
|
+
# When the condition is an action_name comparison the AST can say
|
|
284
|
+
# exactly which action it names; report that instead of nothing.
|
|
285
|
+
actions = extract_action_condition(entry[:option_nodes][:if])
|
|
286
|
+
filter[:if] = actions ? %(action_name == "#{actions.first}") : opts[:if].to_s
|
|
283
287
|
end
|
|
284
288
|
|
|
285
289
|
filter
|
|
@@ -325,10 +329,28 @@ module RailsAiContext
|
|
|
325
329
|
false
|
|
326
330
|
end
|
|
327
331
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
+
# `if: -> { action_name == "create" }` narrows a filter to one action the
|
|
333
|
+
# same way `only:` does. Returns the action name, or nil for any other
|
|
334
|
+
# condition.
|
|
335
|
+
def extract_action_condition(node)
|
|
336
|
+
node = lambda_body(node)
|
|
337
|
+
return nil unless node.is_a?(Prism::CallNode) && node.name == :==
|
|
338
|
+
|
|
339
|
+
receiver = node.receiver
|
|
340
|
+
return nil unless receiver.is_a?(Prism::CallNode) && receiver.name == :action_name && receiver.receiver.nil?
|
|
341
|
+
|
|
342
|
+
operand = node.arguments&.arguments&.first
|
|
343
|
+
case operand
|
|
344
|
+
when Prism::StringNode then [ operand.unescaped ]
|
|
345
|
+
when Prism::SymbolNode then [ operand.value.to_s ]
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def lambda_body(node)
|
|
350
|
+
return node unless node.is_a?(Prism::LambdaNode) || node.is_a?(Prism::BlockNode)
|
|
351
|
+
statements = node.body
|
|
352
|
+
return nil unless statements.is_a?(Prism::StatementsNode) && statements.body.size == 1
|
|
353
|
+
statements.body.first
|
|
332
354
|
end
|
|
333
355
|
|
|
334
356
|
def extract_concerns(ctrl)
|
|
@@ -655,16 +677,21 @@ module RailsAiContext
|
|
|
655
677
|
node.child_nodes.compact.each { |child| find_call_at_line(child, method_name, line_number, constants) }
|
|
656
678
|
end
|
|
657
679
|
|
|
658
|
-
def
|
|
680
|
+
def rate_limit_entry(source)
|
|
659
681
|
return nil if source.nil?
|
|
660
682
|
|
|
661
683
|
ast_result = SourceIntrospector.walk_source(source, {
|
|
662
684
|
rate_limit: -> { Listeners::GenericMacroListener.new(:rate_limit) }
|
|
663
685
|
})
|
|
664
|
-
|
|
665
|
-
|
|
686
|
+
(ast_result[:rate_limit] || []).first
|
|
687
|
+
rescue => e
|
|
688
|
+
$stderr.puts "[rails-ai-context] rate_limit_entry failed: #{e.message}" if ENV["DEBUG"]
|
|
689
|
+
nil
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
def extract_rate_limit(source, entry)
|
|
693
|
+
return nil unless entry
|
|
666
694
|
|
|
667
|
-
entry = entries.first
|
|
668
695
|
line_num = entry[:location]
|
|
669
696
|
lines = source.lines
|
|
670
697
|
return nil unless line_num && line_num > 0 && line_num <= lines.size
|
|
@@ -676,13 +703,16 @@ module RailsAiContext
|
|
|
676
703
|
nil
|
|
677
704
|
end
|
|
678
705
|
|
|
679
|
-
def parse_rate_limit(
|
|
680
|
-
return nil
|
|
706
|
+
def parse_rate_limit(entry)
|
|
707
|
+
return nil unless entry
|
|
708
|
+
|
|
709
|
+
options = entry[:options] || {}
|
|
710
|
+
sources = entry[:option_values] || {}
|
|
681
711
|
|
|
682
712
|
parsed = {}
|
|
683
|
-
parsed[:to] =
|
|
684
|
-
parsed[:within] =
|
|
685
|
-
parsed[:only] =
|
|
713
|
+
parsed[:to] = options[:to] if options[:to].is_a?(Integer)
|
|
714
|
+
parsed[:within] = sources[:within].to_s if sources.key?(:within)
|
|
715
|
+
parsed[:only] = Array(options[:only]).map(&:to_s) if options.key?(:only)
|
|
686
716
|
|
|
687
717
|
parsed.empty? ? nil : parsed
|
|
688
718
|
rescue => e
|
|
@@ -66,11 +66,16 @@ module RailsAiContext
|
|
|
66
66
|
return [] unless Dir.exist?(dir)
|
|
67
67
|
|
|
68
68
|
Dir.glob(File.join(dir, "*.rb")).sort.filter_map do |path|
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
next unless RailsAiContext::SafeFile.read(path)
|
|
70
|
+
|
|
71
|
+
ast = SourceIntrospector.walk(path, {
|
|
72
|
+
initializers: -> { Listeners::GenericMacroListener.new(:initializer) },
|
|
73
|
+
config: Listeners::ConfigAssignmentListener
|
|
74
|
+
})
|
|
75
|
+
|
|
71
76
|
entry = { file: path.sub("#{root}/", "") }
|
|
72
|
-
entry[:initializers] =
|
|
73
|
-
entry[:setup_calls] =
|
|
77
|
+
entry[:initializers] = ast[:initializers].size if ast[:initializers].any?
|
|
78
|
+
entry[:setup_calls] = ast[:config].map { |hit| hit[:path].first.to_s }.uniq.first(10)
|
|
74
79
|
entry
|
|
75
80
|
end
|
|
76
81
|
end
|