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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/CLAUDE.md +2 -0
- data/lib/rails_ai_context/cli/tool_runner.rb +1 -1
- data/lib/rails_ai_context/configuration.rb +24 -0
- data/lib/rails_ai_context/fingerprinter.rb +4 -0
- data/lib/rails_ai_context/introspectors/accessibility_introspector.rb +50 -0
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +8 -1
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +20 -1
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +44 -1
- data/lib/rails_ai_context/introspectors/api_introspector.rb +33 -1
- data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +4 -1
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +48 -0
- data/lib/rails_ai_context/introspectors/component_introspector.rb +6 -0
- data/lib/rails_ai_context/introspectors/config_introspector.rb +47 -3
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +25 -3
- data/lib/rails_ai_context/introspectors/convention_detector.rb +23 -1
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +58 -4
- data/lib/rails_ai_context/introspectors/design_token_introspector.rb +19 -1
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +9 -5
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +8 -1
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +34 -0
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +47 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +47 -2
- data/lib/rails_ai_context/introspectors/job_introspector.rb +40 -1
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +20 -1
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +2 -0
- data/lib/rails_ai_context/introspectors/model_introspector.rb +88 -1
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +45 -6
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +28 -6
- data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +12 -2
- data/lib/rails_ai_context/introspectors/route_introspector.rb +21 -6
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +31 -0
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +51 -2
- data/lib/rails_ai_context/introspectors/test_introspector.rb +42 -1
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +22 -2
- data/lib/rails_ai_context/introspectors/view_introspector.rb +38 -3
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -2
- data/lib/rails_ai_context/resources.rb +1 -1
- data/lib/rails_ai_context/server.rb +6 -3
- data/lib/rails_ai_context/tools/base_tool.rb +1 -1
- data/lib/rails_ai_context/tools/query.rb +5 -1
- data/lib/rails_ai_context/tools/read_logs.rb +3 -0
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6fa4865cb6081dbc1a243371d270db6f80304fd5c3d7ccd8052d2c7c0d672dc6
|
|
4
|
+
data.tar.gz: 055a0fc1462f6a698e004090fda64703e98478af1d8bd1ca3426d85a5e9b6f69
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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/)
|