rails-ai-context 4.3.3 → 4.4.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/CLAUDE.md +2 -0
  4. data/lib/rails_ai_context/cli/tool_runner.rb +1 -1
  5. data/lib/rails_ai_context/configuration.rb +24 -0
  6. data/lib/rails_ai_context/fingerprinter.rb +4 -0
  7. data/lib/rails_ai_context/introspectors/accessibility_introspector.rb +50 -0
  8. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +8 -1
  9. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +20 -1
  10. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +44 -1
  11. data/lib/rails_ai_context/introspectors/api_introspector.rb +33 -1
  12. data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +4 -1
  13. data/lib/rails_ai_context/introspectors/auth_introspector.rb +48 -0
  14. data/lib/rails_ai_context/introspectors/component_introspector.rb +6 -0
  15. data/lib/rails_ai_context/introspectors/config_introspector.rb +47 -3
  16. data/lib/rails_ai_context/introspectors/controller_introspector.rb +25 -3
  17. data/lib/rails_ai_context/introspectors/convention_detector.rb +23 -1
  18. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +58 -4
  19. data/lib/rails_ai_context/introspectors/design_token_introspector.rb +19 -1
  20. data/lib/rails_ai_context/introspectors/devops_introspector.rb +9 -5
  21. data/lib/rails_ai_context/introspectors/engine_introspector.rb +8 -1
  22. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +34 -0
  23. data/lib/rails_ai_context/introspectors/gem_introspector.rb +47 -1
  24. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +47 -2
  25. data/lib/rails_ai_context/introspectors/job_introspector.rb +40 -1
  26. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +20 -1
  27. data/lib/rails_ai_context/introspectors/migration_introspector.rb +2 -0
  28. data/lib/rails_ai_context/introspectors/model_introspector.rb +88 -1
  29. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +45 -6
  30. data/lib/rails_ai_context/introspectors/performance_introspector.rb +28 -6
  31. data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +12 -2
  32. data/lib/rails_ai_context/introspectors/route_introspector.rb +21 -6
  33. data/lib/rails_ai_context/introspectors/schema_introspector.rb +31 -0
  34. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +3 -1
  35. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +51 -2
  36. data/lib/rails_ai_context/introspectors/test_introspector.rb +42 -1
  37. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +22 -2
  38. data/lib/rails_ai_context/introspectors/view_introspector.rb +38 -3
  39. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -2
  40. data/lib/rails_ai_context/resources.rb +1 -1
  41. data/lib/rails_ai_context/server.rb +6 -3
  42. data/lib/rails_ai_context/tools/base_tool.rb +1 -1
  43. data/lib/rails_ai_context/tools/query.rb +5 -1
  44. data/lib/rails_ai_context/tools/read_logs.rb +3 -0
  45. data/lib/rails_ai_context/version.rb +1 -1
  46. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5568327e1027266892b2514cad27a3665532acfa1405236caecda030ff313739
4
- data.tar.gz: 6be68e0d310f9a42aafc47161a676d162debba0cfe8a8f4087ee6051f79900cc
3
+ metadata.gz: 6fa4865cb6081dbc1a243371d270db6f80304fd5c3d7ccd8052d2c7c0d672dc6
4
+ data.tar.gz: 055a0fc1462f6a698e004090fda64703e98478af1d8bd1ca3426d85a5e9b6f69
5
5
  SHA512:
6
- metadata.gz: 151fec5402860aa577e1c21af3ece8c0378a3f5b50f3e18e34236a43061d01da945a869972347e07d2748601c4e7965a449596fecb04cf1eecb0915a0ef73463
7
- data.tar.gz: 18f46e2c0c1d638c3822687f949588dc5f9874cb61cf3f827827205e472456c95d7c55f74cec6a157bfa99531ab4849a9e14a9c23fce7676fc554463a934d9d3
6
+ metadata.gz: ac7a84cb7f54d6106c2bf33c94208f75871b9b8d25d304e0ddde0e36f73d3bd8e973dbf5f81b0665545548fd740fbdf60726a79195889cfbb9683cc733c80307
7
+ data.tar.gz: 440e13668f46d1578933bd5f053ea15cbae494938097f55380a0ab78c3a30a0f92dd74b20ad37edf7aed67f4aade37810e02b141e9d25e8963d40e3d63a650b9
data/CHANGELOG.md CHANGED
@@ -5,6 +5,61 @@ 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
+ ## [4.4.0] — 2026-04-03
9
+
10
+ ### Added
11
+ - **33 introspector enhancements** — every introspector upgraded with new detection capabilities:
12
+ - **SchemaIntrospector**: expression indexes, column comments in static parse, `change_column_default`/`change_column_null` in migration replay
13
+ - **ModelIntrospector**: STI hierarchy detection (parent/children/type column), `attribute` API, enum `_prefix:`/`_suffix:`, `after_commit on:` parsing, inline `private def` exclusion
14
+ - **RouteIntrospector**: route parameter extraction, root route detection, RESTful action flag
15
+ - **JobIntrospector**: SolidQueue recurring job config, Sidekiq config (concurrency/queues), job callbacks (`before_perform`, `around_enqueue`, etc.)
16
+ - **GemIntrospector**: path/git gems from Gemfile, gem group extraction (dev/test/prod)
17
+ - **ConventionDetector**: multi-tenant (Apartment/ActsAsTenant), feature flags (Flipper/LaunchDarkly), error monitoring (Sentry/Bugsnag/Honeybadger), event-driven (Kafka/RabbitMQ/SNS), Zeitwerk detection, STI with type column verification
18
+ - **ControllerIntrospector**: `rate_limit` parsed into structured data (to/within/only), inline `private def` exclusion
19
+ - **StimulusIntrospector**: lifecycle hooks (connect/disconnect/initialize), outlet controller type mapping, action bindings from views (`data-action` parsing)
20
+ - **ViewIntrospector**: `yield`/`content_for` extraction from layouts, conditional layout detection with only/except
21
+ - **TurboIntrospector**: stream action semantics (append/update/remove counts), frame `src` URL extraction
22
+ - **I18nIntrospector**: locale fallback chain detection, locale coverage % per locale
23
+ - **ConfigIntrospector**: cache store options, error monitoring gem detection, job processor config (Sidekiq queues/concurrency)
24
+ - **ActiveStorageIntrospector**: attachment validations (content_type/size), variant definitions
25
+ - **ActionTextIntrospector**: Trix editor customization detection (toolbar/attachment/events)
26
+ - **AuthIntrospector**: OmniAuth provider detection, Devise settings (timeout/lockout/password_length)
27
+ - **ApiIntrospector**: GraphQL resolvers/subscriptions/dataloaders, API pagination strategy detection
28
+ - **TestIntrospector**: shared examples/contexts detection, database cleaner strategy
29
+ - **RakeTaskIntrospector**: task dependencies (`=> :prerequisite`), task arguments (`[:arg1, :arg2]`)
30
+ - **AssetPipelineIntrospector**: Bun bundler, Foundation CSS, PostCSS standalone detection
31
+ - **DevOpsIntrospector**: Fly.io/Render/Railway deployment detection, `docker-compose.yaml` support
32
+ - **ActionMailboxIntrospector**: mailbox callback detection (before/after/around_processing)
33
+ - **MigrationIntrospector**: `change_column_default`, `change_column_null`, `add_check_constraint` action detection
34
+ - **SeedsIntrospector**: CSV loader detection, seed ordering detection
35
+ - **MiddlewareIntrospector**: middleware added via initializers (`config.middleware.use/insert_before`)
36
+ - **EngineIntrospector**: route count + model count inside discovered engines
37
+ - **MultiDatabaseIntrospector**: shard names/keys/count from `connects_to`, improved YAML parsing for nested multi-db configs
38
+ - **ComponentIntrospector**: `**kwargs` splat prop detection
39
+ - **AccessibilityIntrospector**: heading hierarchy (h1-h6), skip link detection, `aria-live` regions, form input analysis (required/types)
40
+ - **PerformanceIntrospector**: polymorphic association compound index detection (`[type, id]`)
41
+ - **FrontendFrameworkIntrospector**: API client detection (Axios/Apollo/SWR/etc.), component library detection (MUI/Radix/shadcn/etc.)
42
+ - **DatabaseStatsIntrospector**: MySQL + SQLite support (was PostgreSQL-only), PostgreSQL dead row counts
43
+ - **ViewTemplateIntrospector**: slot reference detection
44
+ - **DesignTokenIntrospector**: Tailwind arbitrary value extraction
45
+
46
+ ### Fixed
47
+ - **Security: SQLite SQL injection** — `database_stats_introspector` used string interpolation for table names in COUNT queries; now uses `conn.quote_table_name`
48
+ - **Security: query column redaction bypass** — `SELECT password AS pwd` bypassed redaction; now also matches columns ending in `password`, `secret`, `token`, `key`, `digest`, `hash`
49
+ - **Security: log redaction gaps** — added AWS access key (`AKIA...`), JWT token (`eyJ...`), and SSH/TLS private key header patterns
50
+ - **Security: HTTP bind wildcard** — non-loopback warning now catches `0.0.0.0` and `::` (was only checking 3 specific addresses)
51
+ - **Thread safety: `app_size()` race condition** — `SHARED_CACHE[:context]` read without mutex; now wrapped in `SHARED_CACHE[:mutex].synchronize`
52
+ - **Crash: nil callback filter** — `model_introspector` `cb.filter.to_s` crashed on nil filters; added `cb.filter.nil?` guard
53
+ - **Crash: fingerprinter TOCTOU** — `File.mtime` after `File.exist?` could raise `Errno::ENOENT` if file deleted between calls; added rescue
54
+ - **Crash: tool_runner bounds** — `args[i+1]` access without bounds check; added `i + 1 < args.size` guard
55
+ - **Bug: server logs wrong tool list** — logged all 39 `TOOLS` instead of filtered `active_tools` after `skip_tools`; now shows correct count and names
56
+ - **Bug: STI false positive** — convention detector flagged `Admin < User` as STI even without `type` column; now verifies parent's table has `type` column via schema.rb
57
+ - **Bug: resources bare raise** — `raise "Unknown resource"` changed to `raise RailsAiContext::Error`
58
+ - **Config validation** — `http_port` (1-65535), `cache_ttl` (> 0), `max_tool_response_chars` (> 0), `query_row_limit` (1-1000) now validated on assignment
59
+
60
+ ### Changed
61
+ - Test count: 1529 (unchanged — all new features tested via integration test against sample app)
62
+
8
63
  ## [4.3.3] — 2026-04-02
9
64
 
10
65
  ### Fixed
data/CLAUDE.md CHANGED
@@ -56,6 +56,8 @@ structure to AI assistants via the Model Context Protocol (MCP).
56
56
  29. **Docs search** — bundled topic index with weighted keyword search, on-demand GitHub fetch
57
57
  30. **Safe SQL queries** — defense-in-depth: regex pre-filter + SET TRANSACTION READ ONLY + timeout
58
58
  31. **Log reading** — reverse file tail with level filtering and sensitive data redaction
59
+ 32. **Config validation** — `http_port`, `cache_ttl`, `max_tool_response_chars`, `query_row_limit` validated on assignment with clear error messages
60
+ 33. **Sensitive column redaction** — query tool redacts columns by name AND by suffix pattern (password, secret, token, key, digest, hash) to prevent alias bypass
59
61
 
60
62
  ## Testing
61
63
 
@@ -178,7 +178,7 @@ module RailsAiContext
178
178
  next
179
179
  end
180
180
 
181
- value = args[i + 1]
181
+ value = (i + 1 < args.size) ? args[i + 1] : nil
182
182
  if value && !value.start_with?("--")
183
183
  result[key] = coerce_value(value, prop)
184
184
  i += 2
@@ -198,5 +198,29 @@ module RailsAiContext
198
198
  def output_dir_for(app)
199
199
  @output_dir || app.root.to_s
200
200
  end
201
+
202
+ def http_port=(value)
203
+ value = value.to_i
204
+ raise ArgumentError, "http_port must be between 1 and 65535 (got #{value})" unless value.between?(1, 65535)
205
+ @http_port = value
206
+ end
207
+
208
+ def cache_ttl=(value)
209
+ value = value.to_i
210
+ raise ArgumentError, "cache_ttl must be positive (got #{value})" unless value > 0
211
+ @cache_ttl = value
212
+ end
213
+
214
+ def max_tool_response_chars=(value)
215
+ value = value.to_i
216
+ raise ArgumentError, "max_tool_response_chars must be positive (got #{value})" unless value > 0
217
+ @max_tool_response_chars = value
218
+ end
219
+
220
+ def query_row_limit=(value)
221
+ value = value.to_i
222
+ raise ArgumentError, "query_row_limit must be between 1 and 1000 (got #{value})" unless value.between?(1, 1000)
223
+ @query_row_limit = value
224
+ end
201
225
  end
202
226
  end
@@ -50,6 +50,8 @@ module RailsAiContext
50
50
  WATCHED_FILES.each do |file|
51
51
  path = File.join(root, file)
52
52
  digest.update(File.mtime(path).to_f.to_s) if File.exist?(path)
53
+ rescue Errno::ENOENT
54
+ # File deleted between exist? check and mtime read — skip
53
55
  end
54
56
 
55
57
  WATCHED_DIRS.each do |dir|
@@ -58,6 +60,8 @@ module RailsAiContext
58
60
 
59
61
  Dir.glob(File.join(full_dir, "**/*.{rb,rake,js,ts,erb,haml,slim,yml}")).sort.each do |path|
60
62
  digest.update(File.mtime(path).to_f.to_s)
63
+ rescue Errno::ENOENT
64
+ # File deleted between glob and mtime read — skip
61
65
  end
62
66
  end
63
67
 
@@ -21,6 +21,10 @@ module RailsAiContext
21
21
  images: analyze_images(views),
22
22
  labels: analyze_labels(views),
23
23
  landmarks: extract_landmarks(views),
24
+ heading_hierarchy: analyze_heading_hierarchy(views),
25
+ skip_links: detect_skip_links(views),
26
+ live_regions: count_live_regions(views),
27
+ form_inputs: analyze_form_inputs(views),
24
28
  summary: build_summary(views)
25
29
  }
26
30
  rescue => e
@@ -150,6 +154,52 @@ module RailsAiContext
150
154
  landmarks
151
155
  end
152
156
 
157
+ def analyze_heading_hierarchy(views)
158
+ all_content = views.map { |v| v[:content] }.join("\n")
159
+ counts = {}
160
+ (1..6).each do |level|
161
+ count = all_content.scan(/<h#{level}[\s>]/i).size
162
+ counts["h#{level}"] = count if count > 0
163
+ end
164
+ counts
165
+ rescue => e
166
+ $stderr.puts "[rails-ai-context] analyze_heading_hierarchy failed: #{e.message}" if ENV["DEBUG"]
167
+ {}
168
+ end
169
+
170
+ def detect_skip_links(views)
171
+ all_content = views.map { |v| v[:content] }.join("\n")
172
+ {
173
+ skip_to_content: all_content.scan(/skip.to.(?:content|main)/i).size > 0,
174
+ skip_navigation: all_content.scan(/skip.nav/i).size > 0
175
+ }
176
+ rescue => e
177
+ $stderr.puts "[rails-ai-context] detect_skip_links failed: #{e.message}" if ENV["DEBUG"]
178
+ {}
179
+ end
180
+
181
+ def count_live_regions(views)
182
+ all_content = views.map { |v| v[:content] }.join("\n")
183
+ {
184
+ aria_live: all_content.scan(/aria-live=["'](\w+)["']/).flatten.tally,
185
+ aria_atomic: all_content.scan(/aria-atomic=/).size
186
+ }
187
+ rescue => e
188
+ $stderr.puts "[rails-ai-context] count_live_regions failed: #{e.message}" if ENV["DEBUG"]
189
+ {}
190
+ end
191
+
192
+ def analyze_form_inputs(views)
193
+ all_content = views.map { |v| v[:content] }.join("\n")
194
+ {
195
+ required: all_content.scan(/\brequired\b/).size,
196
+ input_types: all_content.scan(/type=["'](\w+)["']/).flatten.tally
197
+ }
198
+ rescue => e
199
+ $stderr.puts "[rails-ai-context] analyze_form_inputs failed: #{e.message}" if ENV["DEBUG"]
200
+ {}
201
+ end
202
+
153
203
  def build_summary(views)
154
204
  all_content = views.map { |v| v[:content] }.join("\n")
155
205
 
@@ -40,7 +40,14 @@ module RailsAiContext
40
40
  { pattern: match[0], action: match[1] }
41
41
  end
42
42
 
43
- { name: name, file: relative, routing: routing }
43
+ # Extract callbacks
44
+ callbacks = content.scan(/\b(before_processing|after_processing|around_processing)\s+:(\w+)/).map do |type, method|
45
+ { type: type, method: method }
46
+ end
47
+
48
+ entry = { name: name, file: relative, routing: routing }
49
+ entry[:callbacks] = callbacks if callbacks.any?
50
+ entry
44
51
  rescue => e
45
52
  $stderr.puts "[rails-ai-context] extract_mailboxes failed: #{e.message}" if ENV["DEBUG"]
46
53
  nil
@@ -13,7 +13,8 @@ module RailsAiContext
13
13
  def call
14
14
  {
15
15
  installed: defined?(ActionText) ? true : false,
16
- rich_text_fields: extract_rich_text_fields
16
+ rich_text_fields: extract_rich_text_fields,
17
+ trix_customizations: detect_trix_customizations
17
18
  }
18
19
  rescue => e
19
20
  { error: e.message }
@@ -25,6 +26,24 @@ module RailsAiContext
25
26
  app.root.to_s
26
27
  end
27
28
 
29
+ def detect_trix_customizations
30
+ customs = []
31
+ js_dirs = [ File.join(app.root, "app", "javascript"), File.join(app.root, "app", "assets", "javascripts") ]
32
+ js_dirs.each do |dir|
33
+ next unless Dir.exist?(dir)
34
+ Dir.glob(File.join(dir, "**", "*.{js,ts}")).each do |path|
35
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
36
+ customs << "custom_toolbar" if content.match?(/Trix\.config\.toolbar/)
37
+ customs << "custom_attachment" if content.match?(/trix-attachment|Trix\.Attachment/)
38
+ customs << "custom_editor" if content.match?(/trix-initialize|trix-change/)
39
+ end
40
+ end
41
+ customs.uniq
42
+ rescue => e
43
+ $stderr.puts "[rails-ai-context] detect_trix_customizations failed: #{e.message}" if ENV["DEBUG"]
44
+ []
45
+ end
46
+
28
47
  def extract_rich_text_fields
29
48
  models_dir = File.join(root, "app/models")
30
49
  return [] unless Dir.exist?(models_dir)
@@ -16,7 +16,9 @@ module RailsAiContext
16
16
  installed: defined?(ActiveStorage) ? true : false,
17
17
  attachments: extract_attachments,
18
18
  storage_services: extract_storage_services,
19
- direct_upload: detect_direct_upload
19
+ direct_upload: detect_direct_upload,
20
+ validations: extract_attachment_validations,
21
+ variants: extract_variants
20
22
  }
21
23
  rescue => e
22
24
  { error: e.message }
@@ -64,6 +66,47 @@ module RailsAiContext
64
66
  []
65
67
  end
66
68
 
69
+ def extract_attachment_validations
70
+ validations = []
71
+ models_dir = File.join(app.root, "app", "models")
72
+ return validations unless Dir.exist?(models_dir)
73
+
74
+ Dir.glob(File.join(models_dir, "**", "*.rb")).each do |path|
75
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
76
+ model = File.basename(path, ".rb").camelize
77
+ content.each_line do |line|
78
+ if (match = line.match(/validates?\s+:(\w+),.*content_type:/))
79
+ validations << { model: model, attachment: match[1], type: "content_type" }
80
+ end
81
+ if (match = line.match(/validates?\s+:(\w+),.*size:/))
82
+ validations << { model: model, attachment: match[1], type: "size" }
83
+ end
84
+ end
85
+ end
86
+ validations
87
+ rescue => e
88
+ $stderr.puts "[rails-ai-context] extract_attachment_validations failed: #{e.message}" if ENV["DEBUG"]
89
+ []
90
+ end
91
+
92
+ def extract_variants
93
+ variants = []
94
+ models_dir = File.join(app.root, "app", "models")
95
+ return variants unless Dir.exist?(models_dir)
96
+
97
+ Dir.glob(File.join(models_dir, "**", "*.rb")).each do |path|
98
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
99
+ model = File.basename(path, ".rb").camelize
100
+ content.scan(/\.variant\s*\(\s*:(\w+)/).each do |name|
101
+ variants << { model: model, name: name[0] }
102
+ end
103
+ end
104
+ variants
105
+ rescue => e
106
+ $stderr.puts "[rails-ai-context] extract_variants failed: #{e.message}" if ENV["DEBUG"]
107
+ []
108
+ end
109
+
67
110
  def detect_direct_upload
68
111
  views_dir = File.join(root, "app/views")
69
112
  js_dir = File.join(root, "app/javascript")
@@ -20,7 +20,9 @@ module RailsAiContext
20
20
  rate_limiting: detect_rate_limiting,
21
21
  openapi_spec: detect_openapi_specs,
22
22
  cors_config: detect_cors_config,
23
- api_client_generation: detect_api_client_generation
23
+ api_client_generation: detect_api_client_generation,
24
+ graphql_details: extract_graphql_details,
25
+ pagination: detect_pagination
24
26
  }
25
27
  rescue => e
26
28
  { error: e.message }
@@ -118,6 +120,36 @@ module RailsAiContext
118
120
  []
119
121
  end
120
122
 
123
+ def extract_graphql_details
124
+ graphql_dir = File.join(app.root, "app", "graphql")
125
+ return nil unless Dir.exist?(graphql_dir)
126
+
127
+ details = {}
128
+ details[:resolvers] = Dir.glob(File.join(graphql_dir, "**", "*resolver*")).map { |f| File.basename(f, ".rb").camelize }
129
+ details[:subscriptions] = Dir.glob(File.join(graphql_dir, "**", "subscriptions", "*.rb")).map { |f| File.basename(f, ".rb").camelize }
130
+ details[:dataloaders] = Dir.glob(File.join(graphql_dir, "**", "{loaders,dataloaders}", "*.rb")).map { |f| File.basename(f, ".rb").camelize }
131
+ details.reject { |_, v| v.empty? }
132
+ rescue => e
133
+ $stderr.puts "[rails-ai-context] extract_graphql_details failed: #{e.message}" if ENV["DEBUG"]
134
+ nil
135
+ end
136
+
137
+ def detect_pagination
138
+ gemfile_lock = File.join(app.root, "Gemfile.lock")
139
+ return nil unless File.exist?(gemfile_lock)
140
+ content = File.read(gemfile_lock)
141
+
142
+ strategies = []
143
+ strategies << "pagy" if content.include?("pagy")
144
+ strategies << "kaminari" if content.include?("kaminari")
145
+ strategies << "will_paginate" if content.include?("will_paginate")
146
+ strategies << "cursor" if content.include?("graphql-pro") # cursor-based pagination
147
+ strategies.empty? ? nil : strategies
148
+ rescue => e
149
+ $stderr.puts "[rails-ai-context] detect_pagination failed: #{e.message}" if ENV["DEBUG"]
150
+ nil
151
+ end
152
+
121
153
  def detect_rate_limiting
122
154
  # Rack::Attack
123
155
  init_path = File.join(root, "config/initializers/rack_attack.rb")
@@ -51,14 +51,17 @@ module RailsAiContext
51
51
  lock_content = read_gemfile_lock
52
52
  return nil unless lock_content
53
53
 
54
- return "tailwindcss" if lock_content.include?("tailwindcss-rails (")
54
+ return "tailwindcss" if lock_content.include?("tailwindcss-rails (") || package_json_has?("tailwindcss")
55
55
  return "bootstrap" if lock_content.include?("bootstrap (") || package_json_has?("bootstrap")
56
56
  return "bulma" if package_json_has?("bulma")
57
+ return "foundation" if package_json_has?("foundation-sites")
58
+ return "postcss" if package_json_has?("postcss") && !package_json_has?("tailwindcss")
57
59
  nil
58
60
  end
59
61
 
60
62
  def detect_js_bundler
61
63
  return "importmap" if File.exist?(File.join(root, "config/importmap.rb"))
64
+ return "bun" if File.exist?(File.join(root, "bun.lockb")) || File.exist?(File.join(root, "bunfig.toml"))
62
65
  return "esbuild" if package_json_has?("esbuild")
63
66
  return "webpack" if File.exist?(File.join(root, "config/webpack")) || package_json_has?("webpack")
64
67
  return "vite" if Dir.glob(File.join(root, "vite.config.*")).any?
@@ -45,6 +45,14 @@ module RailsAiContext
45
45
  secure_pw = scan_models_for(/has_secure_password/)
46
46
  auth[:has_secure_password] = secure_pw.map { |m| m[:model] } if secure_pw.any?
47
47
 
48
+ # OmniAuth providers
49
+ omniauth = detect_omniauth_providers
50
+ auth[:omniauth_providers] = omniauth if omniauth.any?
51
+
52
+ # Devise settings (timeout, lockout, etc.)
53
+ devise_settings = extract_devise_settings
54
+ auth[:devise_settings] = devise_settings unless devise_settings.empty?
55
+
48
56
  auth
49
57
  end
50
58
 
@@ -173,6 +181,46 @@ module RailsAiContext
173
181
  []
174
182
  end
175
183
 
184
+ def detect_omniauth_providers
185
+ providers = []
186
+ initializers = Dir.glob(File.join(app.root, "config", "initializers", "*.rb"))
187
+ initializers.each do |path|
188
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
189
+ content.scan(/config\.omniauth\s+:(\w+)/).each { |m| providers << m[0] }
190
+ content.scan(/provider\s+:(\w+)/).each { |m| providers << m[0] unless %w[developer].include?(m[0]) }
191
+ end
192
+ # Also check model files for omniauth_providers
193
+ models_dir = File.join(app.root, "app", "models")
194
+ if Dir.exist?(models_dir)
195
+ Dir.glob(File.join(models_dir, "**", "*.rb")).each do |path|
196
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
197
+ content.scan(/omniauth_providers:\s*\[([^\]]+)\]/).each do |m|
198
+ m[0].scan(/:(\w+)/).each { |p| providers << p[0] }
199
+ end
200
+ end
201
+ end
202
+ providers.uniq
203
+ rescue => e
204
+ $stderr.puts "[rails-ai-context] detect_omniauth_providers failed: #{e.message}" if ENV["DEBUG"]
205
+ []
206
+ end
207
+
208
+ def extract_devise_settings
209
+ path = File.join(app.root, "config", "initializers", "devise.rb")
210
+ return {} unless File.exist?(path)
211
+ content = File.read(path)
212
+ settings = {}
213
+ settings[:timeout_in] = $1 if content.match(/config\.timeout_in\s*=\s*(\S+)/)
214
+ settings[:lock_strategy] = $1 if content.match(/config\.lock_strategy\s*=\s*:(\w+)/)
215
+ settings[:maximum_attempts] = $1.to_i if content.match(/config\.maximum_attempts\s*=\s*(\d+)/)
216
+ settings[:unlock_strategy] = $1 if content.match(/config\.unlock_strategy\s*=\s*:(\w+)/)
217
+ settings[:password_length] = $1 if content.match(/config\.password_length\s*=\s*(\S+)/)
218
+ settings.empty? ? {} : settings
219
+ rescue => e
220
+ $stderr.puts "[rails-ai-context] extract_devise_settings failed: #{e.message}" if ENV["DEBUG"]
221
+ {}
222
+ end
223
+
176
224
  def scan_models_for(pattern)
177
225
  models_dir = File.join(root, "app/models")
178
226
  return [] unless Dir.exist?(models_dir)
@@ -150,6 +150,12 @@ module RailsAiContext
150
150
  props << prop
151
151
  end
152
152
 
153
+ # Detect **kwargs / **options splat
154
+ if params_str.match?(/\*\*(\w+)/)
155
+ splat_name = params_str.match(/\*\*(\w+)/)[1]
156
+ props << { name: splat_name, splat: true }
157
+ end
158
+
153
159
  props
154
160
  end
155
161
 
@@ -12,7 +12,7 @@ module RailsAiContext
12
12
  end
13
13
 
14
14
  def call
15
- {
15
+ result = {
16
16
  cache_store: detect_cache_store,
17
17
  session_store: detect_session_store,
18
18
  timezone: app.config.time_zone.to_s,
@@ -21,8 +21,19 @@ module RailsAiContext
21
21
  middleware_stack: extract_middleware,
22
22
  initializers: extract_initializers,
23
23
  credentials_configured: credentials_configured?,
24
- current_attributes: detect_current_attributes
25
- }.compact
24
+ current_attributes: detect_current_attributes,
25
+ error_monitoring: detect_error_monitoring,
26
+ job_processor: detect_job_processor_config
27
+ }
28
+
29
+ # Extract cache store options when configured as an Array
30
+ if app.config.cache_store.is_a?(Array) && app.config.cache_store.size > 1
31
+ opts = app.config.cache_store[1..]
32
+ cache_opts = opts.last.is_a?(Hash) ? opts.last.keys.map(&:to_s) : []
33
+ result[:cache_store_options] = cache_opts if cache_opts.any?
34
+ end
35
+
36
+ result.compact
26
37
  rescue => e
27
38
  { error: e.message }
28
39
  end
@@ -123,6 +134,39 @@ module RailsAiContext
123
134
  nil
124
135
  end
125
136
  end
137
+
138
+ def detect_error_monitoring
139
+ gemfile_lock = File.join(app.root, "Gemfile.lock")
140
+ return nil unless File.exist?(gemfile_lock)
141
+ content = File.read(gemfile_lock)
142
+
143
+ tools = []
144
+ tools << "sentry" if content.include?("sentry-ruby") || content.include?("sentry-rails")
145
+ tools << "bugsnag" if content.include?("bugsnag")
146
+ tools << "honeybadger" if content.include?("honeybadger")
147
+ tools << "rollbar" if content.include?("rollbar")
148
+ tools << "airbrake" if content.include?("airbrake")
149
+ tools << "appsignal" if content.include?("appsignal")
150
+ tools.empty? ? nil : tools
151
+ rescue => e
152
+ $stderr.puts "[rails-ai-context] detect_error_monitoring failed: #{e.message}" if ENV["DEBUG"]
153
+ nil
154
+ end
155
+
156
+ def detect_job_processor_config
157
+ config = {}
158
+ sidekiq_path = File.join(app.root, "config", "sidekiq.yml")
159
+ if File.exist?(sidekiq_path)
160
+ content = File.read(sidekiq_path)
161
+ config[:processor] = "sidekiq"
162
+ config[:concurrency] = $1.to_i if content.match(/concurrency:\s*(\d+)/)
163
+ config[:queues] = content.scan(/-\s+(\w+)/).flatten.uniq
164
+ end
165
+ config.empty? ? nil : config
166
+ rescue => e
167
+ $stderr.puts "[rails-ai-context] detect_job_processor_config failed: #{e.message}" if ENV["DEBUG"]
168
+ nil
169
+ end
126
170
  end
127
171
  end
128
172
  end
@@ -86,7 +86,8 @@ module RailsAiContext
86
86
  def extract_details_from_source(path)
87
87
  source = File.read(path)
88
88
  parent = source.match(/class\s+\S+\s*<\s*(\S+)/)&.send(:[], 1) || "Unknown"
89
- {
89
+ rate_limit_raw = extract_rate_limit(source)
90
+ details = {
90
91
  parent_class: parent,
91
92
  api_controller: parent.include?("API"),
92
93
  actions: extract_actions_from_source(source),
@@ -95,15 +96,18 @@ module RailsAiContext
95
96
  strong_params: extract_strong_params(source),
96
97
  respond_to_formats: extract_respond_to(source),
97
98
  rescue_from: extract_rescue_from(source),
98
- rate_limit: extract_rate_limit(source),
99
+ rate_limit: rate_limit_raw,
100
+ rate_limit_parsed: parse_rate_limit(rate_limit_raw),
99
101
  turbo_stream_actions: extract_turbo_stream_actions(source)
100
102
  }.compact
103
+ details
101
104
  rescue => e
102
105
  { error: e.message }
103
106
  end
104
107
 
105
108
  def extract_controller_details(ctrl)
106
109
  source = read_source(ctrl)
110
+ rate_limit_raw = extract_rate_limit(source)
107
111
 
108
112
  {
109
113
  parent_class: ctrl.superclass.name,
@@ -114,7 +118,8 @@ module RailsAiContext
114
118
  strong_params: extract_strong_params(source),
115
119
  respond_to_formats: extract_respond_to(source),
116
120
  rescue_from: extract_rescue_from(source),
117
- rate_limit: extract_rate_limit(source),
121
+ rate_limit: rate_limit_raw,
122
+ rate_limit_parsed: parse_rate_limit(rate_limit_raw),
118
123
  turbo_stream_actions: extract_turbo_stream_actions(source)
119
124
  }.compact
120
125
  end
@@ -150,6 +155,9 @@ module RailsAiContext
150
155
 
151
156
  next if in_private
152
157
 
158
+ # Skip inline private/protected method definitions (e.g. `private def foo`)
159
+ next if line.match?(/\A\s*(?:private|protected)\s+def\s/)
160
+
153
161
  if (match = line.match(/\A\s*def\s+(\w+[?!]?)/))
154
162
  actions << match[1] unless match[1].start_with?("_")
155
163
  end
@@ -361,6 +369,20 @@ module RailsAiContext
361
369
  nil
362
370
  end
363
371
 
372
+ def parse_rate_limit(rate_limit_raw)
373
+ return nil if rate_limit_raw.nil?
374
+
375
+ parsed = {}
376
+ parsed[:to] = $1.to_i if rate_limit_raw.match(/to:\s*(\d+)/)
377
+ parsed[:within] = $1 if rate_limit_raw.match(/within:\s*(\S+)/)
378
+ parsed[:only] = $1.scan(/:(\w+)/).flatten if rate_limit_raw.match(/only:\s*(.+?)(?:,\s*\w+:|$)/)
379
+
380
+ parsed.empty? ? nil : parsed
381
+ rescue => e
382
+ $stderr.puts "[rails-ai-context] parse_rate_limit failed: #{e.message}" if ENV["DEBUG"]
383
+ nil
384
+ end
385
+
364
386
  def extract_turbo_stream_actions(source)
365
387
  return [] if source.nil?
366
388
  return [] unless source.match?(/format\.turbo_stream|\.turbo_stream\.erb/)
@@ -58,6 +58,11 @@ module RailsAiContext
58
58
  %w[dry-validation dry-types dry-struct dry-monads].each do |gem|
59
59
  arch << "dry_rb" if gem_present?(gem)
60
60
  end
61
+ arch << "multi_tenant" if gem_present?("apartment") || gem_present?("acts_as_tenant") || gem_present?("ros-apartment")
62
+ arch << "feature_flags" if gem_present?("flipper") || gem_present?("launchdarkly-server-sdk") || gem_present?("split") || gem_present?("unleash")
63
+ arch << "error_monitoring" if gem_present?("sentry-ruby") || gem_present?("bugsnag") || gem_present?("honeybadger") || gem_present?("rollbar") || gem_present?("airbrake")
64
+ arch << "event_driven" if gem_present?("ruby-kafka") || gem_present?("karafka") || gem_present?("bunny") || gem_present?("sneakers") || gem_present?("aws-sdk-sns") || gem_present?("aws-sdk-sqs")
65
+ arch << "zeitwerk" if defined?(Zeitwerk) && defined?(Rails) && Rails.autoloaders.respond_to?(:main)
61
66
  arch.uniq
62
67
  end
63
68
 
@@ -70,7 +75,24 @@ module RailsAiContext
70
75
  model_files = Dir.glob(File.join(model_dir, "**/*.rb"))
71
76
  content = model_files.first(500).map { |f| File.read(f) rescue "" }.join("\n")
72
77
 
73
- patterns << "sti" if content.match?(/self\.inheritance_column|type.*string/)
78
+ # STI: explicit inheritance_column, or a model that inherits from another app model
79
+ # with a `type` column (verified via schema.rb or model source)
80
+ app_model_names = model_files.filter_map { |f| File.basename(f, ".rb").camelize }
81
+ schema_content = begin
82
+ schema_path = File.join(root, "db/schema.rb")
83
+ File.exist?(schema_path) ? File.read(schema_path) : ""
84
+ rescue
85
+ ""
86
+ end
87
+ has_sti_subclass = model_files.any? do |f|
88
+ src = File.read(f) rescue ""
89
+ parent_match = src.match(/class\s+\w+\s*<\s*(\w+)/)
90
+ next false unless parent_match && app_model_names.include?(parent_match[1]) && parent_match[1] != "ApplicationRecord"
91
+ # Verify parent's table has a `type` column
92
+ parent_table = parent_match[1].underscore.pluralize
93
+ schema_content.match?(/create_table\s+"#{Regexp.escape(parent_table)}".*?t\.\w+\s+"type"/m)
94
+ end
95
+ patterns << "sti" if content.match?(/self\.inheritance_column/) || has_sti_subclass
74
96
  patterns << "polymorphic" if content.match?(/polymorphic:\s*true/)
75
97
  patterns << "soft_delete" if content.match?(/acts_as_paranoid|discard|deleted_at/)
76
98
  patterns << "versioning" if content.match?(/has_paper_trail|audited/)