rails-ai-context 4.5.1 → 4.5.2
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 +28 -0
- data/CLAUDE.md +3 -1
- data/lib/rails_ai_context/introspector.rb +11 -0
- data/lib/rails_ai_context/introspectors/accessibility_introspector.rb +1 -4
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +4 -7
- data/lib/rails_ai_context/introspectors/api_introspector.rb +7 -7
- data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +4 -3
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +16 -10
- data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
- data/lib/rails_ai_context/introspectors/config_introspector.rb +9 -9
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +110 -6
- data/lib/rails_ai_context/introspectors/convention_detector.rb +5 -9
- data/lib/rails_ai_context/introspectors/design_token_introspector.rb +16 -15
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +7 -4
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +2 -1
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +9 -5
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +5 -3
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/job_introspector.rb +5 -3
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +4 -3
- data/lib/rails_ai_context/introspectors/model_introspector.rb +26 -9
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +6 -5
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +153 -37
- data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +2 -1
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +10 -10
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +3 -5
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +4 -3
- data/lib/rails_ai_context/introspectors/test_introspector.rb +12 -14
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +12 -12
- data/lib/rails_ai_context/introspectors/view_introspector.rb +9 -8
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +5 -5
- data/lib/rails_ai_context/markdown_escape.rb +15 -0
- data/lib/rails_ai_context/middleware.rb +17 -0
- data/lib/rails_ai_context/safe_file.rb +20 -0
- data/lib/rails_ai_context/serializers/claude_serializer.rb +12 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +18 -4
- data/lib/rails_ai_context/tools/analyze_feature.rb +8 -43
- data/lib/rails_ai_context/tools/base_tool.rb +21 -2
- data/lib/rails_ai_context/tools/dependency_graph.rb +194 -20
- data/lib/rails_ai_context/tools/generate_test.rb +3 -3
- data/lib/rails_ai_context/tools/get_callbacks.rb +2 -2
- data/lib/rails_ai_context/tools/get_component_catalog.rb +15 -4
- data/lib/rails_ai_context/tools/get_concern.rb +4 -3
- data/lib/rails_ai_context/tools/get_config.rb +63 -16
- data/lib/rails_ai_context/tools/get_controllers.rb +42 -21
- data/lib/rails_ai_context/tools/get_conventions.rb +5 -5
- data/lib/rails_ai_context/tools/get_design_system.rb +1 -1
- data/lib/rails_ai_context/tools/get_edit_context.rb +1 -1
- data/lib/rails_ai_context/tools/get_env.rb +1 -4
- data/lib/rails_ai_context/tools/get_gems.rb +15 -3
- data/lib/rails_ai_context/tools/get_helper_methods.rb +24 -11
- data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -4
- data/lib/rails_ai_context/tools/get_model_details.rb +19 -24
- data/lib/rails_ai_context/tools/get_partial_interface.rb +1 -4
- data/lib/rails_ai_context/tools/get_routes.rb +37 -78
- data/lib/rails_ai_context/tools/get_service_pattern.rb +1 -4
- data/lib/rails_ai_context/tools/get_stimulus.rb +2 -2
- data/lib/rails_ai_context/tools/get_test_info.rb +7 -4
- data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -4
- data/lib/rails_ai_context/tools/get_view.rb +6 -4
- data/lib/rails_ai_context/tools/performance_check.rb +41 -2
- data/lib/rails_ai_context/tools/query.rb +167 -1
- data/lib/rails_ai_context/tools/read_logs.rb +10 -1
- data/lib/rails_ai_context/tools/review_changes.rb +2 -2
- data/lib/rails_ai_context/tools/search_code.rb +17 -20
- data/lib/rails_ai_context/tools/search_docs.rb +5 -4
- data/lib/rails_ai_context/tools/validate.rb +6 -5
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d53c26bdd73eac84be7e9a49ea08c2a50dd0230f969cb29ae240cf2d08b9e4c3
|
|
4
|
+
data.tar.gz: a65a518fab277ff246401cffb32701fb36048f65034b068e20f4b3fe76ddc4eb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0504e0e68f018d325189ccdfd153b7097d1204c906241a9a0dfea41586272b0c2ae296f81704c4e6c4ac9bb414aaab83c5de35e226b7aabff52b984d1bec451e
|
|
7
|
+
data.tar.gz: f2c8a762bd3520edd09e719337043be0a9b7158d0099076118226012cea980ca1877c48459d78810c8e9a1c9d91bf526aad4a2c334521e19c5ac7b715bc44b1e
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ 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.5.2] — 2026-04-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Strong params permit list extraction** — Controller introspector now parses `params.require(:x).permit(...)` calls, returning structured hashes with `requires`, `permits`, `nested`, `arrays`, and `unrestricted` fields. Handles multi-line chains, hash rocket syntax, and `params.permit!` detection
|
|
12
|
+
- **N+1 risk levels** — PerformanceCheck now classifies N+1 risks as `[HIGH]` (no preloading), `[MEDIUM]` (partial preloading), or `[low]` (already preloaded). Detects loop patterns in controller actions, recognizes `.includes`/`.eager_load`/`.preload`, and reports per-action context
|
|
13
|
+
- **DependencyGraph polymorphic/through/cycles/STI** — `show_cycles` param detects circular dependencies via DFS. `show_sti` param groups STI hierarchies. Polymorphic associations resolve concrete types. Through associations render as two-hop edges. Mermaid: dashed arrows for polymorphic, double arrows for through, dotted for STI
|
|
14
|
+
- **Query EXPLAIN support** — New `explain` boolean param wraps SELECT in adapter-specific EXPLAIN (PostgreSQL JSON ANALYZE, MySQL EXPLAIN, SQLite EXPLAIN QUERY PLAN). Parses scan types, indexes, and warnings. Skips row limits for metadata output
|
|
15
|
+
- **GetConfig Rails API integration** — Assets detection now uses FrontendFrameworkIntrospector data instead of regex-parsing package.json. Action Cable uses Rails config API with YAML fallback. New Active Storage service and Action Mailer delivery method detection
|
|
16
|
+
- **Standardized pagination** — `BaseTool.paginate(items, offset:, limit:, default_limit:)` returns `{ items:, hint:, total:, offset:, limit: }`. Adopted across 7 tools: GetControllers, GetModelDetails, GetRoutes, SearchCode, GetGems, GetHelperMethods, GetComponentCatalog. New `offset`/`limit` params added to GetGems, GetHelperMethods, GetComponentCatalog, SearchCode
|
|
17
|
+
- `RailsAiContext::SafeFile` module — safe file reading with configurable size limits, encoding handling, and error suppression
|
|
18
|
+
- `RailsAiContext::MarkdownEscape` module — escapes markdown special characters in dynamic content interpolated into headings and prose
|
|
19
|
+
- **Provider API key redaction** — ReadLogs now redacts Stripe, SendGrid, Slack, GitHub, GitLab, and npm token patterns
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **Middleware crash protection** — MCP HTTP middleware now rescues exceptions and returns a proper JSON-RPC 2.0 error (`-32603 Internal error`) instead of crashing the Rails request pipeline
|
|
23
|
+
- **File read size limits** — Replaced 150+ unguarded `File.read` calls across all introspectors and tools with `SafeFile.read` to prevent OOM on oversized files
|
|
24
|
+
- **Cache race condition** — `BaseTool.cached_context` now returns a `deep_dup` of the shared cache, preventing concurrent MCP requests from mutating shared data structures
|
|
25
|
+
- **Silent failure warnings** — Introspector failures now propagate as `_warnings` to serializer output; AI clients see a `## Warnings` section listing which sections were unavailable and why
|
|
26
|
+
- **Markdown escaping** — Dynamic content in generated markdown is now escaped to prevent formatting corruption from special characters
|
|
27
|
+
- **GetConcern nil crash** — Added nil guard for `SafeFile.read` return value
|
|
28
|
+
- **GenerateTest type coercion** — Fixed `max + 1` crash when `maximum:` validation stored as string
|
|
29
|
+
- **Standalone Bundler conflict** — Resolved gem activation conflict in standalone mode
|
|
30
|
+
- **CLI error messages** — Clean error messages for all CLI error paths
|
|
31
|
+
- **Rake/init parity** — `rake ai:context` and `init` command now match generator output
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
- Test count: 1658 examples (76 new tests for Phase 2 features)
|
|
35
|
+
|
|
8
36
|
## [4.4.0] — 2026-04-03
|
|
9
37
|
|
|
10
38
|
### Added
|
data/CLAUDE.md
CHANGED
|
@@ -15,6 +15,8 @@ structure to AI assistants via the Model Context Protocol (MCP).
|
|
|
15
15
|
- `lib/rails_ai_context/resources.rb` — MCP resources (static data AI clients read directly)
|
|
16
16
|
- `lib/rails_ai_context/server.rb` — MCP server configuration (stdio + HTTP transports)
|
|
17
17
|
- `lib/rails_ai_context/middleware.rb` — Rack middleware for auto-mounting MCP HTTP endpoint
|
|
18
|
+
- `lib/rails_ai_context/safe_file.rb` — Safe file reading with size limits and error handling
|
|
19
|
+
- `lib/rails_ai_context/markdown_escape.rb` — Escapes markdown special characters in dynamic content
|
|
18
20
|
- `lib/rails_ai_context/fingerprinter.rb` — SHA256 file fingerprinting for cache invalidation
|
|
19
21
|
- `lib/rails_ai_context/doctor.rb` — Diagnostic checks and AI readiness scoring
|
|
20
22
|
- `lib/rails_ai_context/live_reload.rb` — MCP live reload: watches files, invalidates caches, notifies AI clients
|
|
@@ -66,7 +68,7 @@ structure to AI assistants via the Model Context Protocol (MCP).
|
|
|
66
68
|
## Testing
|
|
67
69
|
|
|
68
70
|
```bash
|
|
69
|
-
bundle exec rspec # Run specs (
|
|
71
|
+
bundle exec rspec # Run specs (1658 examples)
|
|
70
72
|
bundle exec rubocop # Lint
|
|
71
73
|
```
|
|
72
74
|
|
|
@@ -32,6 +32,17 @@ module RailsAiContext
|
|
|
32
32
|
Rails.logger.warn "[rails-ai-context] #{name} introspection failed: #{e.message}"
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# Collect warnings for introspectors that failed, so serializers can
|
|
36
|
+
# render them and AI clients know which sections are missing.
|
|
37
|
+
warnings = []
|
|
38
|
+
config.introspectors.each do |name|
|
|
39
|
+
data = context[name]
|
|
40
|
+
if data.is_a?(Hash) && data[:error]
|
|
41
|
+
warnings << { introspector: name.to_s, error: data[:error] }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
context[:_warnings] = warnings if warnings.any?
|
|
45
|
+
|
|
35
46
|
context
|
|
36
47
|
end
|
|
37
48
|
|
|
@@ -52,11 +52,8 @@ module RailsAiContext
|
|
|
52
52
|
next unless Dir.exist?(dir)
|
|
53
53
|
|
|
54
54
|
Dir.glob(File.join(dir, "**/*.{erb,haml,slim,html}")).each do |path|
|
|
55
|
-
content =
|
|
55
|
+
content = RailsAiContext::SafeFile.read(path) or next
|
|
56
56
|
views << { file: path.sub("#{root}/", ""), content: content }
|
|
57
|
-
rescue => e
|
|
58
|
-
$stderr.puts "[rails-ai-context] collect_view_content failed: #{e.message}" if ENV["DEBUG"]
|
|
59
|
-
next
|
|
60
57
|
end
|
|
61
58
|
end
|
|
62
59
|
|
|
@@ -33,7 +33,7 @@ module RailsAiContext
|
|
|
33
33
|
relative = path.sub("#{dir}/", "")
|
|
34
34
|
next if relative == "application_mailbox.rb"
|
|
35
35
|
|
|
36
|
-
content =
|
|
36
|
+
content = RailsAiContext::SafeFile.read(path) or next
|
|
37
37
|
name = File.basename(path, ".rb").camelize
|
|
38
38
|
|
|
39
39
|
routing = content.scan(/routing\s+(.+?)\s+=>\s+:(\w+)/).map do |match|
|
|
@@ -32,7 +32,7 @@ module RailsAiContext
|
|
|
32
32
|
js_dirs.each do |dir|
|
|
33
33
|
next unless Dir.exist?(dir)
|
|
34
34
|
Dir.glob(File.join(dir, "**", "*.{js,ts}")).each do |path|
|
|
35
|
-
content =
|
|
35
|
+
content = RailsAiContext::SafeFile.read(path) or next
|
|
36
36
|
customs << "custom_toolbar" if content.match?(/Trix\.config\.toolbar/)
|
|
37
37
|
customs << "custom_attachment" if content.match?(/trix-attachment|Trix\.Attachment/)
|
|
38
38
|
customs << "custom_editor" if content.match?(/trix-initialize|trix-change/)
|
|
@@ -50,7 +50,7 @@ module RailsAiContext
|
|
|
50
50
|
|
|
51
51
|
fields = []
|
|
52
52
|
Dir.glob(File.join(models_dir, "**/*.rb")).each do |path|
|
|
53
|
-
content =
|
|
53
|
+
content = RailsAiContext::SafeFile.read(path) or next
|
|
54
54
|
model_name = File.basename(path, ".rb").camelize
|
|
55
55
|
|
|
56
56
|
content.scan(/has_rich_text\s+:(\w+)/).each do |match|
|
|
@@ -36,7 +36,7 @@ module RailsAiContext
|
|
|
36
36
|
|
|
37
37
|
attachments = []
|
|
38
38
|
Dir.glob(File.join(models_dir, "**/*.rb")).each do |path|
|
|
39
|
-
content =
|
|
39
|
+
content = RailsAiContext::SafeFile.read(path) or next
|
|
40
40
|
model_name = File.basename(path, ".rb").camelize
|
|
41
41
|
|
|
42
42
|
content.scan(/has_one_attached\s+:(\w+)/).each do |match|
|
|
@@ -72,7 +72,7 @@ module RailsAiContext
|
|
|
72
72
|
return validations unless Dir.exist?(models_dir)
|
|
73
73
|
|
|
74
74
|
Dir.glob(File.join(models_dir, "**", "*.rb")).each do |path|
|
|
75
|
-
content =
|
|
75
|
+
content = RailsAiContext::SafeFile.read(path) or next
|
|
76
76
|
model = File.basename(path, ".rb").camelize
|
|
77
77
|
content.each_line do |line|
|
|
78
78
|
if (match = line.match(/validates?\s+:(\w+),.*content_type:/))
|
|
@@ -95,7 +95,7 @@ module RailsAiContext
|
|
|
95
95
|
return variants unless Dir.exist?(models_dir)
|
|
96
96
|
|
|
97
97
|
Dir.glob(File.join(models_dir, "**", "*.rb")).each do |path|
|
|
98
|
-
content =
|
|
98
|
+
content = RailsAiContext::SafeFile.read(path) or next
|
|
99
99
|
model = File.basename(path, ".rb").camelize
|
|
100
100
|
content.scan(/\.variant\s*\(\s*:(\w+)/).each do |name|
|
|
101
101
|
variants << { model: model, name: name[0] }
|
|
@@ -115,10 +115,7 @@ module RailsAiContext
|
|
|
115
115
|
next false unless Dir.exist?(dir)
|
|
116
116
|
Dir.glob(File.join(dir, "**/*")).any? do |f|
|
|
117
117
|
next false if File.directory?(f)
|
|
118
|
-
|
|
119
|
-
rescue => e
|
|
120
|
-
$stderr.puts "[rails-ai-context] detect_direct_upload failed: #{e.message}" if ENV["DEBUG"]
|
|
121
|
-
false
|
|
118
|
+
(RailsAiContext::SafeFile.read(f) || "").match?(/direct.upload|DirectUpload|direct_upload/)
|
|
122
119
|
end
|
|
123
120
|
end
|
|
124
121
|
end
|
|
@@ -96,7 +96,8 @@ module RailsAiContext
|
|
|
96
96
|
cors_path = File.join(root, "config/initializers/cors.rb")
|
|
97
97
|
return nil unless File.exist?(cors_path)
|
|
98
98
|
|
|
99
|
-
content =
|
|
99
|
+
content = RailsAiContext::SafeFile.read(cors_path)
|
|
100
|
+
return nil unless content
|
|
100
101
|
origins = content.scan(/origins\s+(.+)$/).flatten.flat_map do |line|
|
|
101
102
|
line.scan(/["']([^"']+)["']/).flatten
|
|
102
103
|
end
|
|
@@ -111,7 +112,8 @@ module RailsAiContext
|
|
|
111
112
|
package_path = File.join(root, "package.json")
|
|
112
113
|
return [] unless File.exist?(package_path)
|
|
113
114
|
|
|
114
|
-
content =
|
|
115
|
+
content = RailsAiContext::SafeFile.read(package_path)
|
|
116
|
+
return [] unless content
|
|
115
117
|
codegen_tools = %w[openapi-typescript @graphql-codegen/cli orval]
|
|
116
118
|
|
|
117
119
|
codegen_tools.select { |tool| content.include?(%("#{tool}")) }
|
|
@@ -137,7 +139,8 @@ module RailsAiContext
|
|
|
137
139
|
def detect_pagination
|
|
138
140
|
gemfile_lock = File.join(app.root, "Gemfile.lock")
|
|
139
141
|
return nil unless File.exist?(gemfile_lock)
|
|
140
|
-
content =
|
|
142
|
+
content = RailsAiContext::SafeFile.read(gemfile_lock)
|
|
143
|
+
return nil unless content
|
|
141
144
|
|
|
142
145
|
strategies = []
|
|
143
146
|
strategies << "pagy" if content.include?("pagy")
|
|
@@ -159,11 +162,8 @@ module RailsAiContext
|
|
|
159
162
|
controllers_dir = File.join(root, "app/controllers")
|
|
160
163
|
if Dir.exist?(controllers_dir)
|
|
161
164
|
Dir.glob(File.join(controllers_dir, "**/*.rb")).each do |path|
|
|
162
|
-
content =
|
|
165
|
+
content = RailsAiContext::SafeFile.read(path) or next
|
|
163
166
|
return { rails_rate_limiting: true } if content.match?(/rate_limit\b/)
|
|
164
|
-
rescue => e
|
|
165
|
-
$stderr.puts "[rails-ai-context] detect_rate_limiting failed: #{e.message}" if ENV["DEBUG"]
|
|
166
|
-
next
|
|
167
167
|
end
|
|
168
168
|
end
|
|
169
169
|
|
|
@@ -40,7 +40,8 @@ module RailsAiContext
|
|
|
40
40
|
path = File.join(root, "config/importmap.rb")
|
|
41
41
|
return [] unless File.exist?(path)
|
|
42
42
|
|
|
43
|
-
content =
|
|
43
|
+
content = RailsAiContext::SafeFile.read(path)
|
|
44
|
+
return [] unless content
|
|
44
45
|
content.scan(/pin\s+["']([^"']+)["']/).flatten.sort
|
|
45
46
|
rescue => e
|
|
46
47
|
$stderr.puts "[rails-ai-context] extract_importmap_pins failed: #{e.message}" if ENV["DEBUG"]
|
|
@@ -79,7 +80,7 @@ module RailsAiContext
|
|
|
79
80
|
|
|
80
81
|
def read_gemfile_lock
|
|
81
82
|
path = File.join(root, "Gemfile.lock")
|
|
82
|
-
File.exist?(path) ?
|
|
83
|
+
File.exist?(path) ? RailsAiContext::SafeFile.read(path) : nil
|
|
83
84
|
rescue => e
|
|
84
85
|
$stderr.puts "[rails-ai-context] read_gemfile_lock failed: #{e.message}" if ENV["DEBUG"]
|
|
85
86
|
nil
|
|
@@ -88,7 +89,7 @@ module RailsAiContext
|
|
|
88
89
|
def package_json_has?(package)
|
|
89
90
|
path = File.join(root, "package.json")
|
|
90
91
|
return false unless File.exist?(path)
|
|
91
|
-
|
|
92
|
+
(RailsAiContext::SafeFile.read(path) || "").include?("\"#{package}\"")
|
|
92
93
|
rescue => e
|
|
93
94
|
$stderr.puts "[rails-ai-context] package_json_has? failed: #{e.message}" if ENV["DEBUG"]
|
|
94
95
|
false
|
|
@@ -97,7 +97,7 @@ module RailsAiContext
|
|
|
97
97
|
|
|
98
98
|
result = {}
|
|
99
99
|
Dir.glob(File.join(models_dir, "**/*.rb")).each do |path|
|
|
100
|
-
content =
|
|
100
|
+
content = RailsAiContext::SafeFile.read(path) or next
|
|
101
101
|
next unless content.match?(/\bdevise\b/)
|
|
102
102
|
|
|
103
103
|
model_name = File.basename(path, ".rb").camelize
|
|
@@ -134,8 +134,8 @@ module RailsAiContext
|
|
|
134
134
|
|
|
135
135
|
devise_init = File.join(root, "config/initializers/devise.rb")
|
|
136
136
|
if File.exist?(devise_init)
|
|
137
|
-
content =
|
|
138
|
-
return { detected: true, jwt_configured: content
|
|
137
|
+
content = RailsAiContext::SafeFile.read(devise_init)
|
|
138
|
+
return { detected: true, jwt_configured: content&.match?(/config\.jwt\b/) || false }
|
|
139
139
|
end
|
|
140
140
|
|
|
141
141
|
{ detected: true }
|
|
@@ -150,7 +150,8 @@ module RailsAiContext
|
|
|
150
150
|
doorkeeper_init = File.join(root, "config/initializers/doorkeeper.rb")
|
|
151
151
|
return { detected: true } unless File.exist?(doorkeeper_init)
|
|
152
152
|
|
|
153
|
-
content =
|
|
153
|
+
content = RailsAiContext::SafeFile.read(doorkeeper_init)
|
|
154
|
+
return { detected: true } unless content
|
|
154
155
|
|
|
155
156
|
grant_flows = content.scan(/grant_flows\s+%w\[([^\]]+)\]/).flatten.first
|
|
156
157
|
grant_flows = grant_flows&.split&.map(&:strip)
|
|
@@ -171,7 +172,7 @@ module RailsAiContext
|
|
|
171
172
|
return [] unless Dir.exist?(controllers_dir)
|
|
172
173
|
|
|
173
174
|
Dir.glob(File.join(controllers_dir, "**/*.rb")).filter_map do |path|
|
|
174
|
-
content =
|
|
175
|
+
content = RailsAiContext::SafeFile.read(path) or next
|
|
175
176
|
if content.match?(/authenticate_with_http_token|authenticate_or_request_with_http_token/)
|
|
176
177
|
path.sub("#{root}/", "")
|
|
177
178
|
end
|
|
@@ -185,7 +186,7 @@ module RailsAiContext
|
|
|
185
186
|
providers = []
|
|
186
187
|
initializers = Dir.glob(File.join(app.root, "config", "initializers", "*.rb"))
|
|
187
188
|
initializers.each do |path|
|
|
188
|
-
content =
|
|
189
|
+
content = RailsAiContext::SafeFile.read(path) or next
|
|
189
190
|
content.scan(/config\.omniauth\s+:(\w+)/).each { |m| providers << m[0] }
|
|
190
191
|
content.scan(/provider\s+:(\w+)/).each { |m| providers << m[0] unless %w[developer].include?(m[0]) }
|
|
191
192
|
end
|
|
@@ -193,7 +194,7 @@ module RailsAiContext
|
|
|
193
194
|
models_dir = File.join(app.root, "app", "models")
|
|
194
195
|
if Dir.exist?(models_dir)
|
|
195
196
|
Dir.glob(File.join(models_dir, "**", "*.rb")).each do |path|
|
|
196
|
-
content =
|
|
197
|
+
content = RailsAiContext::SafeFile.read(path) or next
|
|
197
198
|
content.scan(/omniauth_providers:\s*\[([^\]]+)\]/).each do |m|
|
|
198
199
|
m[0].scan(/:(\w+)/).each { |p| providers << p[0] }
|
|
199
200
|
end
|
|
@@ -208,7 +209,9 @@ module RailsAiContext
|
|
|
208
209
|
def extract_devise_settings
|
|
209
210
|
path = File.join(app.root, "config", "initializers", "devise.rb")
|
|
210
211
|
return {} unless File.exist?(path)
|
|
211
|
-
content =
|
|
212
|
+
content = RailsAiContext::SafeFile.read(path)
|
|
213
|
+
return {} unless content
|
|
214
|
+
|
|
212
215
|
settings = {}
|
|
213
216
|
settings[:timeout_in] = $1 if content.match(/config\.timeout_in\s*=\s*(\S+)/)
|
|
214
217
|
settings[:lock_strategy] = $1 if content.match(/config\.lock_strategy\s*=\s*:(\w+)/)
|
|
@@ -227,7 +230,7 @@ module RailsAiContext
|
|
|
227
230
|
|
|
228
231
|
results = []
|
|
229
232
|
Dir.glob(File.join(models_dir, "**/*.rb")).each do |path|
|
|
230
|
-
content =
|
|
233
|
+
content = RailsAiContext::SafeFile.read(path) or next
|
|
231
234
|
matches = content.scan(pattern)
|
|
232
235
|
next if matches.empty?
|
|
233
236
|
|
|
@@ -243,7 +246,10 @@ module RailsAiContext
|
|
|
243
246
|
def gem_present?(name)
|
|
244
247
|
lock_path = File.join(root, "Gemfile.lock")
|
|
245
248
|
return false unless File.exist?(lock_path)
|
|
246
|
-
|
|
249
|
+
content = RailsAiContext::SafeFile.read(lock_path)
|
|
250
|
+
return false unless content
|
|
251
|
+
|
|
252
|
+
content.include?(" #{name} (")
|
|
247
253
|
rescue => e
|
|
248
254
|
$stderr.puts "[rails-ai-context] gem_present? failed: #{e.message}" if ENV["DEBUG"]
|
|
249
255
|
false
|
|
@@ -44,7 +44,8 @@ module RailsAiContext
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def parse_component(path)
|
|
47
|
-
content =
|
|
47
|
+
content = RailsAiContext::SafeFile.read(path)
|
|
48
|
+
return nil unless content
|
|
48
49
|
relative = path.sub("#{root}/", "")
|
|
49
50
|
class_name = extract_class_name(content)
|
|
50
51
|
return nil unless class_name
|
|
@@ -115,7 +116,7 @@ module RailsAiContext
|
|
|
115
116
|
return bases unless Dir.exist?(components_dir)
|
|
116
117
|
|
|
117
118
|
Dir.glob(File.join(components_dir, "**/*.rb")).each do |path|
|
|
118
|
-
content =
|
|
119
|
+
content = RailsAiContext::SafeFile.read(path) or next
|
|
119
120
|
if content.match?(/< (Phlex::HTML|Phlex::SVG)\b/)
|
|
120
121
|
match = content.match(/class\s+(\S+)\s*</)
|
|
121
122
|
bases << match[1] if match
|
|
@@ -125,20 +125,18 @@ module RailsAiContext
|
|
|
125
125
|
return [] unless Dir.exist?(models_dir)
|
|
126
126
|
|
|
127
127
|
Dir.glob(File.join(models_dir, "**/*.rb")).filter_map do |path|
|
|
128
|
-
content =
|
|
128
|
+
content = RailsAiContext::SafeFile.read(path) or next
|
|
129
129
|
if content.match?(/< ActiveSupport::CurrentAttributes|< Rails::CurrentAttributes/)
|
|
130
130
|
File.basename(path, ".rb").camelize
|
|
131
131
|
end
|
|
132
|
-
rescue => e
|
|
133
|
-
$stderr.puts "[rails-ai-context] detect_current_attributes failed: #{e.message}" if ENV["DEBUG"]
|
|
134
|
-
nil
|
|
135
132
|
end
|
|
136
133
|
end
|
|
137
134
|
|
|
138
135
|
def detect_error_monitoring
|
|
139
136
|
gemfile_lock = File.join(app.root, "Gemfile.lock")
|
|
140
137
|
return nil unless File.exist?(gemfile_lock)
|
|
141
|
-
content =
|
|
138
|
+
content = RailsAiContext::SafeFile.read(gemfile_lock)
|
|
139
|
+
return nil unless content
|
|
142
140
|
|
|
143
141
|
tools = []
|
|
144
142
|
tools << "sentry" if content.include?("sentry-ruby") || content.include?("sentry-rails")
|
|
@@ -157,10 +155,12 @@ module RailsAiContext
|
|
|
157
155
|
config = {}
|
|
158
156
|
sidekiq_path = File.join(app.root, "config", "sidekiq.yml")
|
|
159
157
|
if File.exist?(sidekiq_path)
|
|
160
|
-
content =
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
158
|
+
content = RailsAiContext::SafeFile.read(sidekiq_path)
|
|
159
|
+
if content
|
|
160
|
+
config[:processor] = "sidekiq"
|
|
161
|
+
config[:concurrency] = $1.to_i if content.match(/concurrency:\s*(\d+)/)
|
|
162
|
+
config[:queues] = content.scan(/-\s+(\w+)/).flatten.uniq
|
|
163
|
+
end
|
|
164
164
|
end
|
|
165
165
|
config.empty? ? nil : config
|
|
166
166
|
rescue => e
|
|
@@ -84,7 +84,8 @@ module RailsAiContext
|
|
|
84
84
|
|
|
85
85
|
# Extract details purely from source file (for controllers not loaded as classes)
|
|
86
86
|
def extract_details_from_source(path)
|
|
87
|
-
source =
|
|
87
|
+
source = RailsAiContext::SafeFile.read(path)
|
|
88
|
+
return { error: "unreadable" } unless source
|
|
88
89
|
parent = source.match(/class\s+\S+\s*<\s*(\S+)/)&.send(:[], 1) || "Unknown"
|
|
89
90
|
rate_limit_raw = extract_rate_limit(source)
|
|
90
91
|
details = {
|
|
@@ -328,7 +329,113 @@ module RailsAiContext
|
|
|
328
329
|
def extract_strong_params(source)
|
|
329
330
|
return [] if source.nil?
|
|
330
331
|
|
|
331
|
-
source.scan(/def\s+(\w+_params)\b/).flatten.uniq
|
|
332
|
+
method_names = source.scan(/def\s+(\w+_params)\b/).flatten.uniq
|
|
333
|
+
method_names.map { |name| extract_permit_details(source, name) }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def extract_permit_details(source, method_name)
|
|
337
|
+
result = { name: method_name }
|
|
338
|
+
body = extract_method_body(source, method_name)
|
|
339
|
+
return result unless body
|
|
340
|
+
|
|
341
|
+
# Detect params.permit! (unrestricted)
|
|
342
|
+
if body.match?(/params\s*\.permit!/)
|
|
343
|
+
result[:unrestricted] = true
|
|
344
|
+
return result
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Extract require(:model)
|
|
348
|
+
if (req = body.match(/params\s*\.require\(\s*:(\w+)\s*\)/))
|
|
349
|
+
result[:requires] = req[1]
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Extract permit(...) — join multi-line into single string
|
|
353
|
+
permit_match = body.match(/\.permit\((.*)\)/m)
|
|
354
|
+
return result unless permit_match
|
|
355
|
+
|
|
356
|
+
permit_body = permit_match[1].gsub(/\s*\n\s*/, " ").strip
|
|
357
|
+
result.merge(parse_permit_args(permit_body))
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def extract_method_body(source, method_name)
|
|
361
|
+
lines = source.lines
|
|
362
|
+
start_idx = lines.index { |l| l.match?(/\bdef\s+#{Regexp.escape(method_name)}\b/) }
|
|
363
|
+
return nil unless start_idx
|
|
364
|
+
|
|
365
|
+
indent = lines[start_idx].match(/^(\s*)/)[1].length
|
|
366
|
+
body_lines = [ lines[start_idx] ]
|
|
367
|
+
|
|
368
|
+
(start_idx + 1...lines.size).each do |i|
|
|
369
|
+
body_lines << lines[i]
|
|
370
|
+
break if lines[i].match?(/^\s{#{indent}}end\b/)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
body_lines.join
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def parse_permit_args(permit_body)
|
|
377
|
+
permits = []
|
|
378
|
+
nested = {}
|
|
379
|
+
arrays = []
|
|
380
|
+
|
|
381
|
+
# Tokenize: split on commas but respect nested brackets
|
|
382
|
+
tokens = split_permit_tokens(permit_body)
|
|
383
|
+
|
|
384
|
+
tokens.each do |token|
|
|
385
|
+
token = token.strip
|
|
386
|
+
if (m = token.match(/\A:(\w+)\s*=>\s*\[([^\]]*)\]\z/))
|
|
387
|
+
# Hash rocket nested: :address => [:street, :city]
|
|
388
|
+
key = m[1]
|
|
389
|
+
vals = m[2].scan(/:(\w+)/).flatten
|
|
390
|
+
if vals.any?
|
|
391
|
+
nested[key] = vals
|
|
392
|
+
else
|
|
393
|
+
arrays << key
|
|
394
|
+
end
|
|
395
|
+
elsif (m = token.match(/\A(\w+):\s*\[([^\]]*)\]\z/))
|
|
396
|
+
# Ruby keyword nested: address: [:street, :city]
|
|
397
|
+
key = m[1]
|
|
398
|
+
vals = m[2].scan(/:(\w+)/).flatten
|
|
399
|
+
if vals.any?
|
|
400
|
+
nested[key] = vals
|
|
401
|
+
else
|
|
402
|
+
arrays << key
|
|
403
|
+
end
|
|
404
|
+
elsif (m = token.match(/\A:(\w+)\z/))
|
|
405
|
+
permits << m[1]
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
result = {}
|
|
410
|
+
result[:permits] = permits if permits.any?
|
|
411
|
+
result[:nested] = nested if nested.any?
|
|
412
|
+
result[:arrays] = arrays if arrays.any?
|
|
413
|
+
result
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def split_permit_tokens(str)
|
|
417
|
+
tokens = []
|
|
418
|
+
current = +""
|
|
419
|
+
depth = 0
|
|
420
|
+
|
|
421
|
+
str.each_char do |ch|
|
|
422
|
+
case ch
|
|
423
|
+
when "[" then depth += 1; current << ch
|
|
424
|
+
when "]" then depth -= 1; current << ch
|
|
425
|
+
when ","
|
|
426
|
+
if depth == 0
|
|
427
|
+
tokens << current
|
|
428
|
+
current = +""
|
|
429
|
+
else
|
|
430
|
+
current << ch
|
|
431
|
+
end
|
|
432
|
+
else
|
|
433
|
+
current << ch
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
tokens << current unless current.strip.empty?
|
|
438
|
+
tokens
|
|
332
439
|
end
|
|
333
440
|
|
|
334
441
|
def extract_respond_to(source)
|
|
@@ -407,10 +514,7 @@ module RailsAiContext
|
|
|
407
514
|
def read_source(ctrl)
|
|
408
515
|
path = source_path(ctrl)
|
|
409
516
|
return nil unless path && File.exist?(path)
|
|
410
|
-
|
|
411
|
-
rescue => e
|
|
412
|
-
$stderr.puts "[rails-ai-context] read_source failed: #{e.message}" if ENV["DEBUG"]
|
|
413
|
-
nil
|
|
517
|
+
RailsAiContext::SafeFile.read(path)
|
|
414
518
|
end
|
|
415
519
|
|
|
416
520
|
def source_path(ctrl)
|
|
@@ -73,19 +73,15 @@ module RailsAiContext
|
|
|
73
73
|
model_dir = File.join(root, "app/models")
|
|
74
74
|
if Dir.exist?(model_dir)
|
|
75
75
|
model_files = Dir.glob(File.join(model_dir, "**/*.rb"))
|
|
76
|
-
content = model_files.first(500).map { |f|
|
|
76
|
+
content = model_files.first(500).map { |f| RailsAiContext::SafeFile.read(f) || "" }.join("\n")
|
|
77
77
|
|
|
78
78
|
# STI: explicit inheritance_column, or a model that inherits from another app model
|
|
79
79
|
# with a `type` column (verified via schema.rb or model source)
|
|
80
80
|
app_model_names = model_files.filter_map { |f| File.basename(f, ".rb").camelize }
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
File.exist?(schema_path) ? File.read(schema_path) : ""
|
|
84
|
-
rescue
|
|
85
|
-
""
|
|
86
|
-
end
|
|
81
|
+
schema_path = File.join(root, "db/schema.rb")
|
|
82
|
+
schema_content = File.exist?(schema_path) ? (RailsAiContext::SafeFile.read(schema_path) || "") : ""
|
|
87
83
|
has_sti_subclass = model_files.any? do |f|
|
|
88
|
-
src =
|
|
84
|
+
src = RailsAiContext::SafeFile.read(f) || ""
|
|
89
85
|
parent_match = src.match(/class\s+\w+\s*<\s*(\w+)/)
|
|
90
86
|
next false unless parent_match && app_model_names.include?(parent_match[1]) && parent_match[1] != "ApplicationRecord"
|
|
91
87
|
# Verify parent's table has a `type` column
|
|
@@ -184,7 +180,7 @@ module RailsAiContext
|
|
|
184
180
|
def gem_present?(name)
|
|
185
181
|
lock_path = File.join(root, "Gemfile.lock")
|
|
186
182
|
return false unless File.exist?(lock_path)
|
|
187
|
-
|
|
183
|
+
(RailsAiContext::SafeFile.read(lock_path) || "").include?(" #{name} (")
|
|
188
184
|
end
|
|
189
185
|
end
|
|
190
186
|
end
|