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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/CONTRIBUTING.md +6 -4
  4. data/docs/ARCHITECTURE.md +1 -1
  5. data/docs/INTROSPECTORS.md +46 -1
  6. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -0
  7. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +2 -0
  8. data/lib/rails_ai_context/introspectors/api_introspector.rb +1 -11
  9. data/lib/rails_ai_context/introspectors/auth_introspector.rb +31 -15
  10. data/lib/rails_ai_context/introspectors/autoload_introspector.rb +18 -9
  11. data/lib/rails_ai_context/introspectors/component_introspector.rb +37 -61
  12. data/lib/rails_ai_context/introspectors/controller_introspector.rb +50 -20
  13. data/lib/rails_ai_context/introspectors/initializer_introspector.rb +9 -4
  14. data/lib/rails_ai_context/introspectors/job_introspector.rb +21 -14
  15. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +42 -4
  16. data/lib/rails_ai_context/introspectors/listeners/chained_call_listener.rb +19 -1
  17. data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +32 -0
  18. data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +118 -0
  19. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +97 -0
  20. data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +8 -5
  21. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -0
  22. data/lib/rails_ai_context/introspectors/model_introspector.rb +1 -5
  23. data/lib/rails_ai_context/introspectors/performance_introspector.rb +25 -21
  24. data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +3 -16
  25. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +5 -10
  26. data/lib/rails_ai_context/introspectors/source_introspector.rb +2 -0
  27. data/lib/rails_ai_context/introspectors/test_introspector.rb +10 -7
  28. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +11 -6
  29. data/lib/rails_ai_context/introspectors/view_introspector.rb +3 -0
  30. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +5 -0
  31. data/lib/rails_ai_context/version.rb +1 -1
  32. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebb8d4e0ebed93183c0c019071ee63d097fb8c7495f551393991000aa5d06e7b
4
- data.tar.gz: 2141cf0a281a173da2f0fa34927be843f93600ced5cdd67ccd6eb4aeef261ce0
3
+ metadata.gz: d4a8615479dc45f376da0873dbd18511feda7dafddad0e4c4c14039d26f8a4bf
4
+ data.tar.gz: 9fa06c53d871f05d30c284964777ebd15b26d44e87284e9232591e288ab47d29
5
5
  SHA512:
6
- metadata.gz: e1bdda115ee2c82a43af9f6389ad18204629539e5867db0ae36d8c4fe86f50547084315470ed5842adccbced095026e2a220f32626d1c1e43600b595d7224afb
7
- data.tar.gz: 197fe45172ce6888fd89ee63ecc505f5e014f1b3c759a918a7244025958ce9d17801f4bf1a0ac3ce401a93063b0f3d75998b4ec485f6bdbe4df9449be333920d
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. Register the key/class pair in `SourceIntrospector::LISTENER_MAP`
56
- 6. Write specs in `spec/lib/rails_ai_context/introspectors/listeners/your_listener_spec.rb`
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\n21 listeners\nConfidence tags"]
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
 
@@ -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 other fourteen listener classes are used through targeted walks (schema dumps, migrations, Gemfiles, rake tasks, initializers, and so on), for 21 in total.
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
- # Extract origin strings from macro args
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
- content = RailsAiContext::SafeFile.read(devise_init)
196
- return { detected: true, jwt_configured: content&.match?(/config\.jwt\b/) || false }
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
- content = RailsAiContext::SafeFile.read(doorkeeper_init)
212
- return { detected: true } unless content
216
+ ast = SourceIntrospector.walk(doorkeeper_init, {
217
+ settings: -> { Listeners::GenericMacroListener.new(:grant_flows, :access_token_expires_in) }
218
+ })
213
219
 
214
- grant_flows = content.scan(/grant_flows\s+%w\[([^\]]+)\]/).flatten.first
215
- grant_flows = grant_flows&.split&.map(&:strip)
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 = content.match(/access_token_expires_in\s+(\S+)/)&.send(:[], 1)
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
- settings[:timeout_in] = $1 if content.match(/config\.timeout_in\s*=\s*(\S+)/)
289
- settings[:lock_strategy] = $1 if content.match(/config\.lock_strategy\s*=\s*:(\w+)/)
290
- settings[:maximum_attempts] = $1.to_i if content.match(/config\.maximum_attempts\s*=\s*(\d+)/)
291
- settings[:unlock_strategy] = $1 if content.match(/config\.unlock_strategy\s*=\s*:(\w+)/)
292
- settings[:password_length] = $1 if content.match(/config\.password_length\s*=\s*(\S+)/)
293
- settings.empty? ? {} : settings
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
- content.scan(/inflect\(?\s*((?:["'][^"']+["']\s*=>\s*["'][^"']+["'],?\s*)+)/).each do |match|
110
- pairs = match[0].scan(/["']([^"']+)["']\s*=>\s*["']([^"']+)["']/)
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 /…/, "…" / inflect.irregular "…", "…"
115
- # Matches both inside `do |inflect|` blocks and via direct Inflector calls.
116
- content.scan(/\binflect\.(acronym|plural|singular|irregular|uncountable|human)\s+["']([^"']+)["'](?:\s*,\s*["']([^"']+)["'])?/).each do |method, arg1, arg2|
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(content)
56
- attach_enum_values_to_props(props, enum_values, content)
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: detect_component_type(content),
63
+ type: type,
62
64
  props: props,
63
- slots: extract_slots(content)
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
- def extract_slots(content)
239
- slots = []
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
- # Use AST for renders_one / renders_many detection
242
- ast_data = SourceIntrospector.walk_source(content, {
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
- slots << slot
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: def slot_name(&block) - keep regex for this (Phlex-specific, diminishing returns)
263
- if detect_component_type(content) == :phlex
264
- content.scan(/def\s+(\w+)\s*\(\s*&\s*\w*\s*\)/).each do |name,|
265
- next if %w[initialize template view_template before_template after_template].include?(name)
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
- # Extracts enumerable values from constants and case statements.
274
- # Returns a hash mapping downcased constant/variable names to arrays of symbol values.
275
- # Detects three patterns:
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
- # Pattern 1: Hash constants - NAME = { key: "value", ... }
283
- content.scan(/([A-Z][A-Z_0-9]*)\s*=\s*\{([^}]*)\}/m) do |name, body|
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
- # Pattern 2: Array constants - NAME = [:sym, :sym, ...]
289
- content.scan(/([A-Z][A-Z_0-9]*)\s*=\s*\[([^\]]*)\]/) do |name, body|
290
- values = body.scan(/:(\w+)/).map(&:first)
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, content)
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: find CONST[@ivar] patterns in the file
306
+ # Constant usage match: CONST[@ivar] ties the prop to that table
332
307
  unless values
333
- content.scan(/([A-Z][A-Z_0-9]*)\[@#{name}\]/) do |const_name,|
334
- if enum_values.key?(const_name.downcase)
335
- values = enum_values[const_name.downcase]
336
- break
337
- end
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
- rate_limit_raw = extract_rate_limit(source)
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: rate_limit_raw,
131
- rate_limit_parsed: parse_rate_limit(rate_limit_raw),
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
- rate_limit_raw = extract_rate_limit(source)
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: rate_limit_raw,
153
- rate_limit_parsed: parse_rate_limit(rate_limit_raw),
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
- filter[:if] = opts[:if].to_s
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
- def extract_action_condition(condition)
329
- return nil unless condition.is_a?(String) || condition.respond_to?(:to_s)
330
- match = condition.to_s.match(/action_name\s*==\s*['"](\w+)['"]/)
331
- match ? [ match[1] ] : nil
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 extract_rate_limit(source)
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
- entries = ast_result[:rate_limit] || []
665
- return nil if entries.empty?
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(rate_limit_raw)
680
- return nil if rate_limit_raw.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] = $1.to_i if rate_limit_raw.match(/to:\s*(\d+)/)
684
- parsed[:within] = $1 if rate_limit_raw.match(/within:\s*(\S+)/)
685
- parsed[:only] = $1.scan(/:(\w+)/).flatten if rate_limit_raw.match(/only:\s*(.+?)(?:,\s*\w+:|$)/)
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
- content = RailsAiContext::SafeFile.read(path) or next
70
- initializer_count = content.scan(/^\s*initializer\s+["']/).size
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] = initializer_count if initializer_count > 0
73
- entry[:setup_calls] = content.scan(/^\s*config\.(\w+)/).flatten.uniq.first(10)
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