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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/CLAUDE.md +3 -1
  4. data/lib/rails_ai_context/introspector.rb +11 -0
  5. data/lib/rails_ai_context/introspectors/accessibility_introspector.rb +1 -4
  6. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
  7. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -2
  8. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +4 -7
  9. data/lib/rails_ai_context/introspectors/api_introspector.rb +7 -7
  10. data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +4 -3
  11. data/lib/rails_ai_context/introspectors/auth_introspector.rb +16 -10
  12. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
  13. data/lib/rails_ai_context/introspectors/config_introspector.rb +9 -9
  14. data/lib/rails_ai_context/introspectors/controller_introspector.rb +110 -6
  15. data/lib/rails_ai_context/introspectors/convention_detector.rb +5 -9
  16. data/lib/rails_ai_context/introspectors/design_token_introspector.rb +16 -15
  17. data/lib/rails_ai_context/introspectors/devops_introspector.rb +7 -4
  18. data/lib/rails_ai_context/introspectors/engine_introspector.rb +2 -1
  19. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +9 -5
  20. data/lib/rails_ai_context/introspectors/gem_introspector.rb +5 -3
  21. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -1
  22. data/lib/rails_ai_context/introspectors/job_introspector.rb +5 -3
  23. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
  24. data/lib/rails_ai_context/introspectors/migration_introspector.rb +4 -3
  25. data/lib/rails_ai_context/introspectors/model_introspector.rb +26 -9
  26. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +6 -5
  27. data/lib/rails_ai_context/introspectors/performance_introspector.rb +153 -37
  28. data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +2 -1
  29. data/lib/rails_ai_context/introspectors/schema_introspector.rb +10 -10
  30. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +3 -5
  31. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +4 -3
  32. data/lib/rails_ai_context/introspectors/test_introspector.rb +12 -14
  33. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +12 -12
  34. data/lib/rails_ai_context/introspectors/view_introspector.rb +9 -8
  35. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +5 -5
  36. data/lib/rails_ai_context/markdown_escape.rb +15 -0
  37. data/lib/rails_ai_context/middleware.rb +17 -0
  38. data/lib/rails_ai_context/safe_file.rb +20 -0
  39. data/lib/rails_ai_context/serializers/claude_serializer.rb +12 -0
  40. data/lib/rails_ai_context/serializers/markdown_serializer.rb +18 -4
  41. data/lib/rails_ai_context/tools/analyze_feature.rb +8 -43
  42. data/lib/rails_ai_context/tools/base_tool.rb +21 -2
  43. data/lib/rails_ai_context/tools/dependency_graph.rb +194 -20
  44. data/lib/rails_ai_context/tools/generate_test.rb +3 -3
  45. data/lib/rails_ai_context/tools/get_callbacks.rb +2 -2
  46. data/lib/rails_ai_context/tools/get_component_catalog.rb +15 -4
  47. data/lib/rails_ai_context/tools/get_concern.rb +4 -3
  48. data/lib/rails_ai_context/tools/get_config.rb +63 -16
  49. data/lib/rails_ai_context/tools/get_controllers.rb +42 -21
  50. data/lib/rails_ai_context/tools/get_conventions.rb +5 -5
  51. data/lib/rails_ai_context/tools/get_design_system.rb +1 -1
  52. data/lib/rails_ai_context/tools/get_edit_context.rb +1 -1
  53. data/lib/rails_ai_context/tools/get_env.rb +1 -4
  54. data/lib/rails_ai_context/tools/get_gems.rb +15 -3
  55. data/lib/rails_ai_context/tools/get_helper_methods.rb +24 -11
  56. data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -4
  57. data/lib/rails_ai_context/tools/get_model_details.rb +19 -24
  58. data/lib/rails_ai_context/tools/get_partial_interface.rb +1 -4
  59. data/lib/rails_ai_context/tools/get_routes.rb +37 -78
  60. data/lib/rails_ai_context/tools/get_service_pattern.rb +1 -4
  61. data/lib/rails_ai_context/tools/get_stimulus.rb +2 -2
  62. data/lib/rails_ai_context/tools/get_test_info.rb +7 -4
  63. data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -4
  64. data/lib/rails_ai_context/tools/get_view.rb +6 -4
  65. data/lib/rails_ai_context/tools/performance_check.rb +41 -2
  66. data/lib/rails_ai_context/tools/query.rb +167 -1
  67. data/lib/rails_ai_context/tools/read_logs.rb +10 -1
  68. data/lib/rails_ai_context/tools/review_changes.rb +2 -2
  69. data/lib/rails_ai_context/tools/search_code.rb +17 -20
  70. data/lib/rails_ai_context/tools/search_docs.rb +5 -4
  71. data/lib/rails_ai_context/tools/validate.rb +6 -5
  72. data/lib/rails_ai_context/version.rb +1 -1
  73. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f75359acda5dcf480e297948934c7be7500fb3af5ff9daacd952a77105186e8
4
- data.tar.gz: 17bfbc76237408f30a51d2b67bf4a5a8ed72b437bf03824bc41a937bd600477b
3
+ metadata.gz: d53c26bdd73eac84be7e9a49ea08c2a50dd0230f969cb29ae240cf2d08b9e4c3
4
+ data.tar.gz: a65a518fab277ff246401cffb32701fb36048f65034b068e20f4b3fe76ddc4eb
5
5
  SHA512:
6
- metadata.gz: 4cbe45e0efb3f6fc078cebd82dc0e1f6ac7246c6ea9ad53928c5588d1ddd1b937911e805f2d88f268c226d0277423860efbf860911cec5c8f1fb39c9d21d474f
7
- data.tar.gz: bef426277ceda95f46d3bad899e188c6716b5d838af0b02b5d40349d3944b5a951cbe47889762b23b58b962e96fb7cd229ff2e775dedb1f35a2feb0342a8203a
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 (1529 examples)
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 = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
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 = File.read(path)
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 = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
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 = File.read(path)
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 = File.read(path)
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 = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
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 = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
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
- File.read(f).match?(/direct.upload|DirectUpload|direct_upload/)
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 = File.read(cors_path)
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 = File.read(package_path, encoding: "bom|utf-8")
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 = File.read(gemfile_lock)
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 = File.read(path)
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 = File.read(path)
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) ? File.read(path) : nil
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
- File.read(path).include?("\"#{package}\"")
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 = File.read(path) rescue next
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 = File.read(devise_init)
138
- return { detected: true, jwt_configured: content.match?(/config\.jwt\b/) }
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 = File.read(doorkeeper_init)
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 = File.read(path) rescue next
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 = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
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 = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
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 = File.read(path)
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 = File.read(path)
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
- File.read(lock_path).include?(" #{name} (")
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 = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
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 = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
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 = File.read(path)
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 = File.read(gemfile_lock)
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 = 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
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 = File.read(path)
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
- File.read(path)
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| File.read(f) rescue "" }.join("\n")
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
- 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
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 = File.read(f) rescue ""
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
- File.read(lock_path).include?(" #{name} (")
183
+ (RailsAiContext::SafeFile.read(lock_path) || "").include?(" #{name} (")
188
184
  end
189
185
  end
190
186
  end