rails-ai-context 4.3.1 → 4.3.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 +12 -0
- data/CLAUDE.md +1 -1
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +13 -16
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +3 -3
- data/lib/rails_ai_context/serializers/claude_serializer.rb +3 -3
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +2 -1
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +3 -3
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +4 -4
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +3 -3
- data/lib/rails_ai_context/tools/analyze_feature.rb +24 -1
- data/lib/rails_ai_context/tools/base_tool.rb +22 -7
- data/lib/rails_ai_context/tools/dependency_graph.rb +4 -1
- data/lib/rails_ai_context/tools/diagnose.rb +5 -5
- data/lib/rails_ai_context/tools/generate_test.rb +32 -4
- data/lib/rails_ai_context/tools/get_context.rb +1 -0
- data/lib/rails_ai_context/tools/get_model_details.rb +1 -0
- data/lib/rails_ai_context/tools/get_partial_interface.rb +11 -4
- data/lib/rails_ai_context/tools/get_schema.rb +1 -0
- data/lib/rails_ai_context/tools/migration_advisor.rb +6 -3
- data/lib/rails_ai_context/tools/onboard.rb +105 -21
- data/lib/rails_ai_context/tools/performance_check.rb +45 -28
- data/lib/rails_ai_context/tools/query.rb +24 -0
- data/lib/rails_ai_context/tools/review_changes.rb +13 -12
- data/lib/rails_ai_context/tools/session_context.rb +13 -8
- data/lib/rails_ai_context/tools/validate.rb +5 -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: 5081505ca1d99874de52ff33fb72ff75877b67ec79984fb9bb9004dbcbf4ea27
|
|
4
|
+
data.tar.gz: cc6781e87f54708aab290044a33aee7aff3ddb7579fd4fe4f592eb5d3721b310
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '09c0346700785aa79211b6fddf7e52dfedf9989500abd53538fc451ca3b96b0f0450865a2cbad86d6c26ac2aaefd4e19a8dcc66587960674a0adefcc324d34a9'
|
|
7
|
+
data.tar.gz: dfd9f8d968e87b736d026e9cf3fe98ef08f61527ad4be1a9c96f8abe8a9d8b47b15d0133970df3e9fd0d4f915de87c0d893686f236f9b903a1ef53ef2c406e67
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ 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.3.2] — 2026-04-02
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **review_changes undefined variable** — `changed_tests` (NameError at runtime) replaced with correct `test_files` variable in `detect_warnings`
|
|
12
|
+
- **N+1 introspector O(n*m*k) view scan** — `detect_n_plus_one` now pre-loads all view file contents once via `preload_view_contents` instead of re-globbing per model+association pair
|
|
13
|
+
- **atomic write collision** — temp filenames now include `SecureRandom.hex(4)` suffix to prevent concurrent process collisions on the same file
|
|
14
|
+
- **bare rescue; end across 7 serializers + 2 tools** — all 16 occurrences replaced with `rescue => e` + stderr logging so errors are visible instead of silently swallowed
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- Test count: 1176 → 1529 (+353 new tests)
|
|
18
|
+
- 26 new spec files covering previously untested tools, serializer helpers, introspectors, and infrastructure (server, engine, resources, watcher)
|
|
19
|
+
|
|
8
20
|
## [4.3.1] — 2026-04-02
|
|
9
21
|
|
|
10
22
|
### Fixed
|
data/CLAUDE.md
CHANGED
|
@@ -103,26 +103,25 @@ module RailsAiContext
|
|
|
103
103
|
controllers_dir = File.join(root, "app/controllers")
|
|
104
104
|
return risks unless Dir.exist?(controllers_dir)
|
|
105
105
|
|
|
106
|
+
# Pre-scan all view files once to avoid O(n*m*k) glob inside nested loop
|
|
107
|
+
view_contents = preload_view_contents
|
|
108
|
+
|
|
106
109
|
Dir.glob(File.join(controllers_dir, "**/*.rb")).each do |path|
|
|
107
110
|
content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
|
|
108
111
|
relative = path.sub("#{root}/", "")
|
|
109
112
|
|
|
110
|
-
# Pattern: Model.all or Model.where(...) followed by iteration
|
|
111
|
-
# without .includes
|
|
112
113
|
model_data.each do |model|
|
|
113
114
|
model[:has_many].each do |assoc|
|
|
114
|
-
# Check if controller fetches this model's collection without includes
|
|
115
115
|
model_ref = Regexp.escape(model[:name])
|
|
116
116
|
pattern = /#{model_ref}\.(all|where|order|limit|find_each)\b/
|
|
117
117
|
next unless content.match?(pattern)
|
|
118
118
|
|
|
119
|
-
# Check if .includes is used for this association
|
|
120
119
|
includes_pattern = /\.includes\(.*:#{Regexp.escape(assoc[:name])}/
|
|
121
120
|
next if content.match?(includes_pattern)
|
|
122
121
|
|
|
123
|
-
# Check views for
|
|
124
|
-
|
|
125
|
-
next unless
|
|
122
|
+
# Check pre-loaded views for association access
|
|
123
|
+
assoc_pattern = /\.#{Regexp.escape(assoc[:name])}\b/
|
|
124
|
+
next unless view_contents.any? { |vc| vc.match?(assoc_pattern) }
|
|
126
125
|
|
|
127
126
|
risks << {
|
|
128
127
|
model: model[:name],
|
|
@@ -132,23 +131,21 @@ module RailsAiContext
|
|
|
132
131
|
}
|
|
133
132
|
end
|
|
134
133
|
end
|
|
135
|
-
rescue
|
|
134
|
+
rescue StandardError
|
|
136
135
|
next
|
|
137
136
|
end
|
|
138
137
|
|
|
139
138
|
risks.uniq { |r| [ r[:model], r[:association], r[:controller] ] }
|
|
140
139
|
end
|
|
141
140
|
|
|
142
|
-
def
|
|
141
|
+
def preload_view_contents
|
|
143
142
|
views_dir = File.join(root, "app/views")
|
|
144
|
-
return
|
|
143
|
+
return [] unless Dir.exist?(views_dir)
|
|
145
144
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
rescue
|
|
151
|
-
false
|
|
145
|
+
Dir.glob(File.join(views_dir, "**/*.{erb,haml,slim}")).filter_map do |path|
|
|
146
|
+
File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
|
|
147
|
+
rescue StandardError
|
|
148
|
+
nil
|
|
152
149
|
end
|
|
153
150
|
end
|
|
154
151
|
|
|
@@ -84,7 +84,7 @@ module RailsAiContext
|
|
|
84
84
|
lines << "" << "**Global before_actions:** #{before_actions.join(', ')}"
|
|
85
85
|
end
|
|
86
86
|
end
|
|
87
|
-
rescue; end
|
|
87
|
+
rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
|
|
88
88
|
|
|
89
89
|
lines << ""
|
|
90
90
|
lines << "ALWAYS use MCP tools for context — do NOT read reference files directly."
|
|
@@ -259,7 +259,7 @@ module RailsAiContext
|
|
|
259
259
|
partials.each { |p| lines << "- #{p}" }
|
|
260
260
|
end
|
|
261
261
|
end
|
|
262
|
-
rescue; end
|
|
262
|
+
rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
|
|
263
263
|
|
|
264
264
|
# Helpers — so agents use existing helpers instead of creating new ones
|
|
265
265
|
begin
|
|
@@ -272,7 +272,7 @@ module RailsAiContext
|
|
|
272
272
|
lines << helper_methods.map { |m| "- #{m}" }.join("\n")
|
|
273
273
|
end
|
|
274
274
|
end
|
|
275
|
-
rescue; end
|
|
275
|
+
rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
|
|
276
276
|
|
|
277
277
|
# Stimulus controllers — so agents reuse existing controllers
|
|
278
278
|
stim = context[:stimulus]
|
|
@@ -176,7 +176,7 @@ module RailsAiContext
|
|
|
176
176
|
.map { |f| File.basename(f, ".rb").camelize }
|
|
177
177
|
.reject { |s| s == "ApplicationService" }
|
|
178
178
|
lines << "" << "**Services:** #{service_files.join(', ')}" if service_files.any?
|
|
179
|
-
rescue; end
|
|
179
|
+
rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
|
|
180
180
|
end
|
|
181
181
|
|
|
182
182
|
if dir_struct["app/jobs"]
|
|
@@ -186,7 +186,7 @@ module RailsAiContext
|
|
|
186
186
|
.map { |f| File.basename(f, ".rb").camelize }
|
|
187
187
|
.reject { |j| j == "ApplicationJob" }
|
|
188
188
|
lines << "**Jobs:** #{job_files.join(', ')}" if job_files.any?
|
|
189
|
-
rescue; end
|
|
189
|
+
rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
|
|
190
190
|
end
|
|
191
191
|
|
|
192
192
|
lines << ""
|
|
@@ -263,7 +263,7 @@ module RailsAiContext
|
|
|
263
263
|
lines << "- Global before_actions: #{before_actions.join(', ')}"
|
|
264
264
|
end
|
|
265
265
|
end
|
|
266
|
-
rescue; end
|
|
266
|
+
rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
|
|
267
267
|
|
|
268
268
|
lines << ""
|
|
269
269
|
lines
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "securerandom"
|
|
4
5
|
|
|
5
6
|
module RailsAiContext
|
|
6
7
|
module Serializers
|
|
@@ -127,7 +128,7 @@ module RailsAiContext
|
|
|
127
128
|
def atomic_write(filepath, content)
|
|
128
129
|
dir = File.dirname(filepath)
|
|
129
130
|
FileUtils.mkdir_p(dir)
|
|
130
|
-
tmp = File.join(dir, ".#{File.basename(filepath)}.tmp")
|
|
131
|
+
tmp = File.join(dir, ".#{File.basename(filepath)}.#{SecureRandom.hex(4)}.tmp")
|
|
131
132
|
File.write(tmp, content)
|
|
132
133
|
File.rename(tmp, filepath)
|
|
133
134
|
end
|
|
@@ -95,7 +95,7 @@ module RailsAiContext
|
|
|
95
95
|
.reject { |s| s == "ApplicationService" }
|
|
96
96
|
lines << "- Services: #{service_files.join(', ')}" if service_files.any?
|
|
97
97
|
end
|
|
98
|
-
rescue; end
|
|
98
|
+
rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
|
|
99
99
|
|
|
100
100
|
# List jobs
|
|
101
101
|
begin
|
|
@@ -107,7 +107,7 @@ module RailsAiContext
|
|
|
107
107
|
.reject { |j| j == "ApplicationJob" }
|
|
108
108
|
lines << "- Jobs: #{job_files.join(', ')}" if job_files.any?
|
|
109
109
|
end
|
|
110
|
-
rescue; end
|
|
110
|
+
rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
|
|
111
111
|
|
|
112
112
|
# ApplicationController before_actions
|
|
113
113
|
begin
|
|
@@ -118,7 +118,7 @@ module RailsAiContext
|
|
|
118
118
|
before_actions = source.scan(/before_action\s+:([\w!?]+)/).flatten
|
|
119
119
|
lines << "" << "**Global before_actions:** #{before_actions.join(', ')}" if before_actions.any?
|
|
120
120
|
end
|
|
121
|
-
rescue; end
|
|
121
|
+
rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
|
|
122
122
|
|
|
123
123
|
lines << ""
|
|
124
124
|
lines << "Use MCP tools for detailed data. Start with `detail:\"summary\"`."
|
|
@@ -103,7 +103,7 @@ module RailsAiContext
|
|
|
103
103
|
.reject { |s| s == "ApplicationService" }
|
|
104
104
|
lines << "- Services: #{service_files.join(', ')}" if service_files.any?
|
|
105
105
|
end
|
|
106
|
-
rescue; end
|
|
106
|
+
rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
|
|
107
107
|
|
|
108
108
|
# List jobs
|
|
109
109
|
begin
|
|
@@ -115,7 +115,7 @@ module RailsAiContext
|
|
|
115
115
|
.reject { |j| j == "ApplicationJob" }
|
|
116
116
|
lines << "- Jobs: #{job_files.join(', ')}" if job_files.any?
|
|
117
117
|
end
|
|
118
|
-
rescue; end
|
|
118
|
+
rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
|
|
119
119
|
|
|
120
120
|
# ApplicationController before_actions
|
|
121
121
|
begin
|
|
@@ -126,7 +126,7 @@ module RailsAiContext
|
|
|
126
126
|
before_actions = source.scan(/before_action\s+:([\w!?]+)/).flatten
|
|
127
127
|
lines << "" << "Global before_actions: #{before_actions.join(', ')}" if before_actions.any?
|
|
128
128
|
end
|
|
129
|
-
rescue; end
|
|
129
|
+
rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
|
|
130
130
|
|
|
131
131
|
lines << ""
|
|
132
132
|
lines << "MCP tools available — see rails-mcp-tools.mdc for full reference."
|
|
@@ -235,7 +235,7 @@ module RailsAiContext
|
|
|
235
235
|
partials.each { |p| lines << "- #{p}" }
|
|
236
236
|
end
|
|
237
237
|
end
|
|
238
|
-
rescue; end
|
|
238
|
+
rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
|
|
239
239
|
|
|
240
240
|
# Stimulus controllers
|
|
241
241
|
stim = context[:stimulus]
|
|
@@ -115,7 +115,7 @@ module RailsAiContext
|
|
|
115
115
|
before_actions = source.scan(/before_action\s+:([\w!?]+)/).flatten
|
|
116
116
|
lines << "**Global before_actions:** #{before_actions.join(', ')}" << "" if before_actions.any?
|
|
117
117
|
end
|
|
118
|
-
rescue; end
|
|
118
|
+
rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
|
|
119
119
|
|
|
120
120
|
app_controllers.keys.sort.first(25).each do |name|
|
|
121
121
|
info = app_controllers[name]
|
|
@@ -137,7 +137,7 @@ module RailsAiContext
|
|
|
137
137
|
.reject { |s| s == "ApplicationService" }
|
|
138
138
|
lines << "" << "**Services:** #{service_files.join(', ')}" if service_files.any?
|
|
139
139
|
end
|
|
140
|
-
rescue; end
|
|
140
|
+
rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
|
|
141
141
|
|
|
142
142
|
# List jobs
|
|
143
143
|
begin
|
|
@@ -149,7 +149,7 @@ module RailsAiContext
|
|
|
149
149
|
.reject { |j| j == "ApplicationJob" }
|
|
150
150
|
lines << "**Jobs:** #{job_files.join(', ')}" if job_files.any?
|
|
151
151
|
end
|
|
152
|
-
rescue; end
|
|
152
|
+
rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
|
|
153
153
|
|
|
154
154
|
lines << ""
|
|
155
155
|
lines << "Use `rails_get_controllers(controller:\"Name\", action:\"index\")` for one action's source code."
|
|
@@ -21,9 +21,14 @@ module RailsAiContext
|
|
|
21
21
|
|
|
22
22
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
23
23
|
|
|
24
|
+
# Map well-known feature keywords to gem-based patterns
|
|
25
|
+
AUTH_KEYWORDS = %w[auth authentication login signup signin session devise omniauth].freeze
|
|
26
|
+
AUTH_GEM_NAMES = %w[devise omniauth rodauth sorcery clearance authlogic warden jwt].freeze
|
|
27
|
+
|
|
24
28
|
def self.call(feature:, server_context: nil) # rubocop:disable Metrics
|
|
25
29
|
feature = feature.to_s.strip
|
|
26
30
|
return text_response("Please provide a feature keyword (e.g. 'cook', 'payment', 'authentication').") if feature.empty?
|
|
31
|
+
set_call_params(feature: feature)
|
|
27
32
|
|
|
28
33
|
ctx = cached_context
|
|
29
34
|
pattern = feature.downcase
|
|
@@ -48,6 +53,19 @@ module RailsAiContext
|
|
|
48
53
|
discover_accessibility(ctx, pattern, lines)
|
|
49
54
|
discover_components(ctx, pattern, lines)
|
|
50
55
|
|
|
56
|
+
# For auth-related keywords, also discover auth gems
|
|
57
|
+
if AUTH_KEYWORDS.include?(pattern)
|
|
58
|
+
gems = ctx[:gems]
|
|
59
|
+
if gems.is_a?(Hash) && !gems[:error]
|
|
60
|
+
notable = gems[:notable_gems] || []
|
|
61
|
+
auth_gems = notable.select { |g| AUTH_GEM_NAMES.include?(g[:name]) }
|
|
62
|
+
if auth_gems.any?
|
|
63
|
+
lines << "" << "## Auth Gems" << ""
|
|
64
|
+
auth_gems.each { |g| lines << "- **#{g[:name]}** #{g[:version]}#{g[:config] ? " (config: #{g[:config]})" : ""}" }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
51
69
|
# If nothing was discovered, return a clean "no match" with real suggestions
|
|
52
70
|
has_content = lines.any? { |l| l.start_with?("## ") || l.start_with?("### ") }
|
|
53
71
|
unless has_content
|
|
@@ -65,11 +83,16 @@ module RailsAiContext
|
|
|
65
83
|
# --- AF: Models ---
|
|
66
84
|
def discover_models(ctx, pattern, lines)
|
|
67
85
|
models = ctx[:models] || {}
|
|
86
|
+
|
|
87
|
+
# For auth-related keywords, also match the User model and auth-related concerns
|
|
88
|
+
extra_auth_match = AUTH_KEYWORDS.include?(pattern)
|
|
89
|
+
|
|
68
90
|
matched = models.select do |name, data|
|
|
69
91
|
next false if data[:error]
|
|
70
92
|
name.downcase.include?(pattern) ||
|
|
71
93
|
data[:table_name]&.downcase&.include?(pattern) ||
|
|
72
|
-
name.underscore.include?(pattern)
|
|
94
|
+
name.underscore.include?(pattern) ||
|
|
95
|
+
(extra_auth_match && (name == "User" || data[:concerns]&.any? { |c| c.to_s.downcase.match?(/authenticat|devise/) }))
|
|
73
96
|
end
|
|
74
97
|
|
|
75
98
|
if matched.any?
|
|
@@ -65,12 +65,20 @@ module RailsAiContext
|
|
|
65
65
|
def session_record(tool_name, params, summary = nil)
|
|
66
66
|
SESSION_CONTEXT[:mutex].synchronize do
|
|
67
67
|
key = session_key(tool_name, params)
|
|
68
|
-
SESSION_CONTEXT[:queries][key]
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
summary
|
|
73
|
-
|
|
68
|
+
existing = SESSION_CONTEXT[:queries][key]
|
|
69
|
+
if existing
|
|
70
|
+
existing[:call_count] = (existing[:call_count] || 1) + 1
|
|
71
|
+
existing[:last_timestamp] = Time.now.iso8601
|
|
72
|
+
existing[:summary] = summary if summary
|
|
73
|
+
else
|
|
74
|
+
SESSION_CONTEXT[:queries][key] = {
|
|
75
|
+
tool: tool_name.to_s,
|
|
76
|
+
params: params,
|
|
77
|
+
call_count: 1,
|
|
78
|
+
timestamp: Time.now.iso8601,
|
|
79
|
+
summary: summary
|
|
80
|
+
}
|
|
81
|
+
end
|
|
74
82
|
end
|
|
75
83
|
end
|
|
76
84
|
|
|
@@ -155,13 +163,20 @@ module RailsAiContext
|
|
|
155
163
|
end
|
|
156
164
|
end
|
|
157
165
|
|
|
166
|
+
# Store call params for the current tool invocation (thread-safe)
|
|
167
|
+
def set_call_params(**params)
|
|
168
|
+
Thread.current[:rails_ai_context_call_params] = params.reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
|
|
169
|
+
end
|
|
170
|
+
|
|
158
171
|
# Helper: wrap text in an MCP::Tool::Response with safety-net truncation.
|
|
159
172
|
# Auto-records the call in session context so session_context(action:"status") works.
|
|
160
173
|
def text_response(text)
|
|
161
174
|
# Auto-track: record this tool call in session context (skip SessionContext itself to avoid recursion)
|
|
162
175
|
if respond_to?(:tool_name) && tool_name != "rails_session_context"
|
|
163
176
|
summary = text.lines.first&.strip&.truncate(80)
|
|
164
|
-
|
|
177
|
+
params = Thread.current[:rails_ai_context_call_params] || {}
|
|
178
|
+
session_record(tool_name, params, summary)
|
|
179
|
+
Thread.current[:rails_ai_context_call_params] = nil
|
|
165
180
|
end
|
|
166
181
|
|
|
167
182
|
max = RailsAiContext.configuration.max_tool_response_chars
|
|
@@ -41,6 +41,8 @@ module RailsAiContext
|
|
|
41
41
|
model = model.to_s.strip if model
|
|
42
42
|
depth = [ [ depth.to_i, 1 ].max, 3 ].min
|
|
43
43
|
|
|
44
|
+
set_call_params(model: model, depth: depth, format: format)
|
|
45
|
+
|
|
44
46
|
# Build adjacency list from model associations
|
|
45
47
|
graph = build_graph(models_data)
|
|
46
48
|
|
|
@@ -48,7 +50,8 @@ module RailsAiContext
|
|
|
48
50
|
# Filter to subgraph centered on the model
|
|
49
51
|
model_key = find_model_key(model, graph.keys)
|
|
50
52
|
unless model_key
|
|
51
|
-
return not_found_response("
|
|
53
|
+
return not_found_response("Model", model, graph.keys.sort,
|
|
54
|
+
recovery_tool: "Call rails_dependency_graph() without model to see all models")
|
|
52
55
|
end
|
|
53
56
|
subgraph = extract_subgraph(graph, model_key, depth)
|
|
54
57
|
else
|
|
@@ -265,7 +265,7 @@ module RailsAiContext
|
|
|
265
265
|
lines << text
|
|
266
266
|
lines << ""
|
|
267
267
|
end
|
|
268
|
-
rescue; end
|
|
268
|
+
rescue => e; $stderr.puts "[rails-ai-context] Diagnosis step skipped: #{e.message}"; end
|
|
269
269
|
end
|
|
270
270
|
end
|
|
271
271
|
|
|
@@ -284,7 +284,7 @@ module RailsAiContext
|
|
|
284
284
|
lines << text
|
|
285
285
|
lines << ""
|
|
286
286
|
end
|
|
287
|
-
rescue; end
|
|
287
|
+
rescue => e; $stderr.puts "[rails-ai-context] Diagnosis step skipped: #{e.message}"; end
|
|
288
288
|
end
|
|
289
289
|
end
|
|
290
290
|
|
|
@@ -298,7 +298,7 @@ module RailsAiContext
|
|
|
298
298
|
lines << text
|
|
299
299
|
lines << ""
|
|
300
300
|
end
|
|
301
|
-
rescue; end
|
|
301
|
+
rescue => e; $stderr.puts "[rails-ai-context] Diagnosis step skipped: #{e.message}"; end
|
|
302
302
|
end
|
|
303
303
|
|
|
304
304
|
lines
|
|
@@ -329,7 +329,7 @@ module RailsAiContext
|
|
|
329
329
|
"This variable may not be set in all code paths — check if it's assigned before use, " \
|
|
330
330
|
"or use `#{receiver}&.#{method}` for safe navigation."
|
|
331
331
|
end
|
|
332
|
-
rescue; end
|
|
332
|
+
rescue => e; $stderr.puts "[rails-ai-context] Diagnosis step skipped: #{e.message}"; end
|
|
333
333
|
end
|
|
334
334
|
end
|
|
335
335
|
|
|
@@ -346,7 +346,7 @@ module RailsAiContext
|
|
|
346
346
|
"The record with the given ID doesn't exist or doesn't belong to the current user. " \
|
|
347
347
|
"Check if the record was deleted or if the user is authorized to access it."
|
|
348
348
|
end
|
|
349
|
-
rescue; end
|
|
349
|
+
rescue => e; $stderr.puts "[rails-ai-context] Diagnosis step skipped: #{e.message}"; end
|
|
350
350
|
end
|
|
351
351
|
end
|
|
352
352
|
|
|
@@ -272,7 +272,7 @@ module RailsAiContext
|
|
|
272
272
|
if factory
|
|
273
273
|
lines << " @#{setup_var} = create(:#{factory})"
|
|
274
274
|
elsif fixtures
|
|
275
|
-
fixture_key =
|
|
275
|
+
fixture_key = resolve_fixture_key(name, fixture_names)
|
|
276
276
|
lines << " @#{setup_var} = #{name.underscore.pluralize}(#{fixture_key})"
|
|
277
277
|
else
|
|
278
278
|
lines << " @#{setup_var} = #{name}.new"
|
|
@@ -471,10 +471,12 @@ module RailsAiContext
|
|
|
471
471
|
|
|
472
472
|
has_devise = tests_data[:test_helper_setup]&.any? { |h| h.include?("Devise") }
|
|
473
473
|
if has_devise
|
|
474
|
+
fixture_names = tests_data[:fixture_names] || {}
|
|
475
|
+
user_fixture_key = resolve_fixture_key("User", fixture_names)
|
|
474
476
|
lines << " include Devise::Test::IntegrationHelpers"
|
|
475
477
|
lines << ""
|
|
476
478
|
lines << " setup do"
|
|
477
|
-
lines << " @user = users(
|
|
479
|
+
lines << " @user = users(#{user_fixture_key})"
|
|
478
480
|
lines << " sign_in @user"
|
|
479
481
|
lines << " end"
|
|
480
482
|
end
|
|
@@ -490,12 +492,15 @@ module RailsAiContext
|
|
|
490
492
|
|
|
491
493
|
lines << ""
|
|
492
494
|
lines << " test \"#{r[:verb]} #{r[:path]} works\" do"
|
|
495
|
+
fixture_names = tests_data[:fixture_names] || {}
|
|
493
496
|
param_names.each do |param|
|
|
494
497
|
if param == "id"
|
|
495
|
-
|
|
498
|
+
fk = resolve_fixture_key(snake.singularize.camelize, fixture_names)
|
|
499
|
+
lines << " #{param} = #{snake.pluralize}(#{fk}).id"
|
|
496
500
|
elsif param.end_with?("_id")
|
|
497
501
|
resource = param.delete_suffix("_id")
|
|
498
|
-
|
|
502
|
+
fk = resolve_fixture_key(resource.camelize, fixture_names)
|
|
503
|
+
lines << " #{param} = #{resource.pluralize}(#{fk}).id"
|
|
499
504
|
end
|
|
500
505
|
end
|
|
501
506
|
lines << " #{verb} \"#{quoted_path}\""
|
|
@@ -555,6 +560,29 @@ module RailsAiContext
|
|
|
555
560
|
|
|
556
561
|
# ── Helpers ──────────────────────────────────────────────────────
|
|
557
562
|
|
|
563
|
+
# Resolve the best fixture key for a model by reading actual fixture file contents.
|
|
564
|
+
# Falls back to :one if no fixture file is found.
|
|
565
|
+
def resolve_fixture_key(model_name, fixture_names)
|
|
566
|
+
plural = model_name.underscore.pluralize
|
|
567
|
+
# fixture_names is { "users" => [:chef_one, :chef_two], "cooks" => [:pending_cook, ...] }
|
|
568
|
+
keys = fixture_names[plural] || fixture_names[plural.to_sym]
|
|
569
|
+
if keys.is_a?(Array) && keys.any?
|
|
570
|
+
":#{keys.first}"
|
|
571
|
+
else
|
|
572
|
+
# Try reading the fixture file directly
|
|
573
|
+
fixture_file = File.join(Rails.root, "test", "fixtures", "#{plural}.yml")
|
|
574
|
+
if File.exist?(fixture_file)
|
|
575
|
+
content = File.read(fixture_file, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue nil
|
|
576
|
+
if content
|
|
577
|
+
# YAML fixture files have top-level keys as fixture names
|
|
578
|
+
first_key = content.scan(/^([a-z_]\w*):/i).first&.first
|
|
579
|
+
return ":#{first_key}" if first_key
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
":one"
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
558
586
|
def find_factory_name(model_name, tests_data)
|
|
559
587
|
factory_names = tests_data[:factory_names] || {}
|
|
560
588
|
underscore = model_name.underscore
|
|
@@ -37,6 +37,7 @@ module RailsAiContext
|
|
|
37
37
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
38
38
|
|
|
39
39
|
def self.call(controller: nil, action: nil, model: nil, feature: nil, include: nil, server_context: nil)
|
|
40
|
+
set_call_params(controller: controller, action: action, model: model, feature: feature)
|
|
40
41
|
result = if controller && action
|
|
41
42
|
controller_action_context(controller, action)
|
|
42
43
|
elsif controller
|
|
@@ -33,6 +33,7 @@ module RailsAiContext
|
|
|
33
33
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
34
34
|
|
|
35
35
|
def self.call(model: nil, detail: "standard", limit: nil, offset: 0, server_context: nil)
|
|
36
|
+
set_call_params(model: model, detail: detail)
|
|
36
37
|
models = cached_context[:models]
|
|
37
38
|
return text_response("Model introspection not available. Add :models to introspectors.") unless models
|
|
38
39
|
return text_response("Model introspection failed: #{models[:error]}") if models[:error]
|
|
@@ -332,18 +332,24 @@ module RailsAiContext
|
|
|
332
332
|
private_class_method def self.find_render_sites(views_dir, partial, root)
|
|
333
333
|
sites = []
|
|
334
334
|
# Build search names: the partial can be referenced multiple ways
|
|
335
|
+
# Normalize: strip underscore prefix from basename and extensions
|
|
335
336
|
parts = partial.split("/")
|
|
336
337
|
basename = parts.last.delete_prefix("_").sub(/\..*\z/, "")
|
|
337
338
|
dir_prefix = parts[0...-1].join("/")
|
|
338
339
|
|
|
340
|
+
# Build the canonical render name (how Rails references partials in render calls)
|
|
341
|
+
# "shared/_status_badge.html.erb" → "shared/status_badge"
|
|
342
|
+
# "_status_badge" → "status_badge"
|
|
343
|
+
canonical = (dir_prefix.empty? ? basename : "#{dir_prefix}/#{basename}")
|
|
344
|
+
|
|
339
345
|
# Possible render references:
|
|
340
346
|
# render "shared/status_badge"
|
|
341
347
|
# render partial: "shared/status_badge"
|
|
342
348
|
# render "status_badge" (from same directory)
|
|
343
349
|
search_patterns = [
|
|
344
|
-
|
|
345
|
-
basename
|
|
346
|
-
]
|
|
350
|
+
canonical, # shared/status_badge
|
|
351
|
+
basename # status_badge
|
|
352
|
+
].uniq
|
|
347
353
|
|
|
348
354
|
view_files = Dir.glob(File.join(views_dir, "**", "*.{erb,haml,slim}")).sort
|
|
349
355
|
|
|
@@ -357,7 +363,8 @@ module RailsAiContext
|
|
|
357
363
|
content.each_line.with_index(1) do |line, line_num|
|
|
358
364
|
search_patterns.each do |search_name|
|
|
359
365
|
# Match render "partial_name" or render partial: "partial_name"
|
|
360
|
-
|
|
366
|
+
# Allow content before search_name (e.g. "shared/status_badge" matches "status_badge")
|
|
367
|
+
next unless line.match?(/render\s.*["'][^"']*#{Regexp.escape(search_name)}["']/)
|
|
361
368
|
|
|
362
369
|
# For short basename matches, verify directory context
|
|
363
370
|
if search_name == basename && dir_prefix.length > 0
|
|
@@ -43,6 +43,7 @@ module RailsAiContext
|
|
|
43
43
|
)
|
|
44
44
|
|
|
45
45
|
def self.call(table: nil, detail: "standard", limit: nil, offset: 0, format: "markdown", server_context: nil)
|
|
46
|
+
set_call_params(table: table, detail: detail)
|
|
46
47
|
schema = cached_context[:schema]
|
|
47
48
|
return text_response("Schema introspection not available. Add :schema to introspectors.") unless schema
|
|
48
49
|
return text_response("Schema introspection not available: #{schema[:error]}") if schema[:error]
|
|
@@ -108,7 +108,8 @@ module RailsAiContext
|
|
|
108
108
|
private
|
|
109
109
|
|
|
110
110
|
def migration_class_name(action, table, column = nil)
|
|
111
|
-
|
|
111
|
+
preposition = action == "remove" ? "From" : "To"
|
|
112
|
+
parts = [ action.camelize, column&.camelize, preposition, table.camelize ].compact
|
|
112
113
|
parts.join
|
|
113
114
|
end
|
|
114
115
|
|
|
@@ -286,6 +287,9 @@ module RailsAiContext
|
|
|
286
287
|
|
|
287
288
|
opts = options ? ", #{options}" : ""
|
|
288
289
|
|
|
290
|
+
# Detect original column type from schema for a reversible down method
|
|
291
|
+
original_type = find_column_type(table, column, cached_context[:schema]) || "string"
|
|
292
|
+
|
|
289
293
|
lines << "**Warning:** Changing column type may cause data loss if types are incompatible."
|
|
290
294
|
lines << ""
|
|
291
295
|
lines << "```ruby"
|
|
@@ -295,8 +299,7 @@ module RailsAiContext
|
|
|
295
299
|
lines << " end"
|
|
296
300
|
lines << ""
|
|
297
301
|
lines << " def down"
|
|
298
|
-
lines << "
|
|
299
|
-
lines << " change_column :#{table}, :#{column}, :original_type"
|
|
302
|
+
lines << " change_column :#{table}, :#{column}, :#{original_type}"
|
|
300
303
|
lines << " end"
|
|
301
304
|
lines << "end"
|
|
302
305
|
lines << "```"
|
|
@@ -183,22 +183,60 @@ module RailsAiContext
|
|
|
183
183
|
|
|
184
184
|
def section_auth(ctx)
|
|
185
185
|
auth = ctx[:auth]
|
|
186
|
-
return [] unless auth.is_a?(Hash) && !auth[:error]
|
|
187
|
-
|
|
188
|
-
authentication = auth[:authentication] || {}
|
|
189
|
-
authorization = auth[:authorization] || {}
|
|
190
|
-
return [] if authentication.empty? && authorization.empty?
|
|
191
|
-
|
|
192
186
|
lines = [ "## Authentication & Authorization", "" ]
|
|
193
|
-
|
|
194
|
-
|
|
187
|
+
has_content = false
|
|
188
|
+
|
|
189
|
+
if auth.is_a?(Hash) && !auth[:error]
|
|
190
|
+
authentication = auth[:authentication] || {}
|
|
191
|
+
authorization = auth[:authorization] || {}
|
|
192
|
+
if authentication[:method]
|
|
193
|
+
lines << "Authentication is handled by #{authentication[:method]}."
|
|
194
|
+
has_content = true
|
|
195
|
+
end
|
|
196
|
+
if authentication[:model]
|
|
197
|
+
lines << "The #{authentication[:model]} model handles user accounts."
|
|
198
|
+
has_content = true
|
|
199
|
+
end
|
|
200
|
+
if authorization[:method]
|
|
201
|
+
lines << "Authorization uses #{authorization[:method]}."
|
|
202
|
+
has_content = true
|
|
203
|
+
end
|
|
195
204
|
end
|
|
196
|
-
|
|
197
|
-
|
|
205
|
+
|
|
206
|
+
# Fallback: detect auth from gems if introspector didn't provide data
|
|
207
|
+
unless has_content
|
|
208
|
+
gems = ctx[:gems]
|
|
209
|
+
if gems.is_a?(Hash) && !gems[:error]
|
|
210
|
+
notable = gems[:notable_gems] || []
|
|
211
|
+
auth_gem_names = %w[devise omniauth rodauth sorcery clearance authlogic]
|
|
212
|
+
auth_gems = notable.select { |g| g.is_a?(Hash) && auth_gem_names.include?(g[:name].to_s) }
|
|
213
|
+
if auth_gems.any?
|
|
214
|
+
lines << "Authentication via #{auth_gems.map { |g| "#{g[:name]}#{g[:version] ? " (#{g[:version]})" : ""}" }.join(', ')}."
|
|
215
|
+
has_content = true
|
|
216
|
+
end
|
|
217
|
+
authz_gem_names = %w[pundit cancancan action_policy rolify]
|
|
218
|
+
authz_gems = notable.select { |g| g.is_a?(Hash) && authz_gem_names.include?(g[:name].to_s) }
|
|
219
|
+
if authz_gems.any?
|
|
220
|
+
lines << "Authorization via #{authz_gems.map { |g| g[:name] }.join(', ')}."
|
|
221
|
+
has_content = true
|
|
222
|
+
end
|
|
223
|
+
end
|
|
198
224
|
end
|
|
199
|
-
|
|
200
|
-
|
|
225
|
+
|
|
226
|
+
# Fallback: detect from conventions (global before_actions like authenticate_user!)
|
|
227
|
+
unless has_content
|
|
228
|
+
conv = ctx[:conventions]
|
|
229
|
+
if conv.is_a?(Hash) && !conv[:error]
|
|
230
|
+
before_acts = Array(conv[:before_actions]).select { |a| a.to_s.match?(/authenticat|authorize/) }
|
|
231
|
+
auth_checks = Array(conv[:authorization_checks]) + before_acts
|
|
232
|
+
if auth_checks.any?
|
|
233
|
+
lines << "Auth checks detected: #{auth_checks.first(5).join(', ')}."
|
|
234
|
+
has_content = true
|
|
235
|
+
end
|
|
236
|
+
end
|
|
201
237
|
end
|
|
238
|
+
|
|
239
|
+
return [] unless has_content
|
|
202
240
|
lines << ""
|
|
203
241
|
lines
|
|
204
242
|
end
|
|
@@ -356,16 +394,42 @@ module RailsAiContext
|
|
|
356
394
|
turbo = ctx[:turbo]
|
|
357
395
|
jobs = ctx[:jobs]
|
|
358
396
|
channels = (jobs.is_a?(Hash) ? jobs[:channels] : nil) || []
|
|
359
|
-
|
|
397
|
+
has_content = false
|
|
360
398
|
|
|
361
399
|
lines = [ "## Real-Time Features", "" ]
|
|
362
400
|
if channels.any?
|
|
363
401
|
names = channels.map { |c| c[:name] || c[:class_name] }.compact
|
|
364
402
|
lines << "Action Cable channels: #{names.join(', ')}."
|
|
403
|
+
has_content = true
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
if turbo.is_a?(Hash) && !turbo[:error]
|
|
407
|
+
broadcasts = turbo[:broadcasts] || turbo[:explicit_broadcasts] || []
|
|
408
|
+
if broadcasts.any?
|
|
409
|
+
lines << "Turbo Stream broadcasts: #{broadcasts.size} broadcast points."
|
|
410
|
+
has_content = true
|
|
411
|
+
end
|
|
412
|
+
streams = turbo[:stream_subscriptions] || turbo[:subscriptions] || []
|
|
413
|
+
if streams.any?
|
|
414
|
+
lines << "Turbo Stream subscriptions: #{streams.size}."
|
|
415
|
+
has_content = true
|
|
416
|
+
end
|
|
365
417
|
end
|
|
366
|
-
|
|
367
|
-
|
|
418
|
+
|
|
419
|
+
# Fallback: check for turbo_stream usage in views
|
|
420
|
+
unless has_content
|
|
421
|
+
views = ctx[:view_templates] || ctx[:views]
|
|
422
|
+
if views.is_a?(Hash) && !views[:error]
|
|
423
|
+
templates = Array(views[:templates])
|
|
424
|
+
turbo_views = templates.select { |v| v.is_a?(Hash) && (v[:path].to_s.include?("turbo_stream") || Array(v[:turbo_streams]).any?) }
|
|
425
|
+
if turbo_views.any?
|
|
426
|
+
lines << "Turbo Stream templates: #{turbo_views.size}."
|
|
427
|
+
has_content = true
|
|
428
|
+
end
|
|
429
|
+
end
|
|
368
430
|
end
|
|
431
|
+
|
|
432
|
+
return [] unless has_content
|
|
369
433
|
lines << ""
|
|
370
434
|
lines
|
|
371
435
|
end
|
|
@@ -407,13 +471,33 @@ module RailsAiContext
|
|
|
407
471
|
|
|
408
472
|
def section_devops(ctx)
|
|
409
473
|
devops = ctx[:devops]
|
|
410
|
-
return [] unless devops.is_a?(Hash) && !devops[:error]
|
|
411
|
-
|
|
412
474
|
lines = [ "## Deployment & DevOps", "" ]
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
475
|
+
has_content = false
|
|
476
|
+
|
|
477
|
+
if devops.is_a?(Hash) && !devops[:error]
|
|
478
|
+
lines << "Dockerfile: #{devops[:dockerfile] ? 'present' : 'not found'}."
|
|
479
|
+
lines << "Procfile: #{devops[:procfile] ? 'present' : 'not found'}." if devops.key?(:procfile)
|
|
480
|
+
deploy = devops[:deployment_method]
|
|
481
|
+
lines << "Deployment: #{deploy}." if deploy
|
|
482
|
+
has_content = true
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Fallback: check for Dockerfile/Procfile directly
|
|
486
|
+
unless has_content
|
|
487
|
+
root = Rails.root.to_s
|
|
488
|
+
has_dockerfile = File.exist?(File.join(root, "Dockerfile")) || File.exist?(File.join(root, "Dockerfile.dev"))
|
|
489
|
+
has_procfile = File.exist?(File.join(root, "Procfile")) || File.exist?(File.join(root, "Procfile.dev"))
|
|
490
|
+
has_ci = Dir.exist?(File.join(root, ".github", "workflows")) || File.exist?(File.join(root, ".gitlab-ci.yml"))
|
|
491
|
+
|
|
492
|
+
if has_dockerfile || has_procfile || has_ci
|
|
493
|
+
lines << "Dockerfile: #{has_dockerfile ? 'present' : 'not found'}."
|
|
494
|
+
lines << "Procfile: #{has_procfile ? 'present' : 'not found'}."
|
|
495
|
+
lines << "CI: #{has_ci ? 'detected' : 'not found'}."
|
|
496
|
+
has_content = true
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
return [] unless has_content
|
|
417
501
|
lines << ""
|
|
418
502
|
lines
|
|
419
503
|
end
|
|
@@ -46,23 +46,40 @@ module RailsAiContext
|
|
|
46
46
|
if models_data.is_a?(Hash) && !models_data[:error]
|
|
47
47
|
model_names = models_data.keys.map(&:to_s)
|
|
48
48
|
unless model_names.any? { |m| m.downcase == model.downcase }
|
|
49
|
-
return not_found_response("
|
|
49
|
+
return not_found_response("Model", model, model_names,
|
|
50
|
+
recovery_tool: "Call rails_performance_check() without model filter to see all issues")
|
|
50
51
|
end
|
|
51
52
|
end
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
lines = [ "# Performance Analysis", "" ]
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
# Collect all items then filter, so the count reflects actual displayed results
|
|
58
|
+
all_sections = {}
|
|
59
|
+
all_sections[:n_plus_one] = data[:n_plus_one_risks] || []
|
|
60
|
+
all_sections[:counter_cache] = data[:missing_counter_cache] || []
|
|
61
|
+
all_sections[:indexes] = data[:missing_fk_indexes] || []
|
|
62
|
+
all_sections[:model_all] = data[:model_all_in_controllers] || []
|
|
63
|
+
all_sections[:eager_load] = data[:eager_load_candidates] || []
|
|
64
|
+
|
|
65
|
+
# Apply model filter to count
|
|
66
|
+
filtered_count = if model && !model.empty?
|
|
67
|
+
all_sections.values.sum { |items| filter_items(items, model).size }
|
|
68
|
+
elsif category != "all"
|
|
69
|
+
(all_sections[category.to_sym] || []).size
|
|
70
|
+
else
|
|
71
|
+
all_sections.values.sum(&:size)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
lines << "**Total issues found:** #{filtered_count}"
|
|
58
75
|
lines << ""
|
|
59
76
|
|
|
60
77
|
if detail == "summary"
|
|
61
|
-
lines << "- N+1 risks: #{
|
|
62
|
-
lines << "- Missing counter_cache: #{
|
|
63
|
-
lines << "- Missing FK indexes: #{
|
|
64
|
-
lines << "- Model.all in controllers: #{
|
|
65
|
-
lines << "- Eager load candidates: #{
|
|
78
|
+
lines << "- N+1 risks: #{filter_items(all_sections[:n_plus_one], model).size}"
|
|
79
|
+
lines << "- Missing counter_cache: #{filter_items(all_sections[:counter_cache], model).size}"
|
|
80
|
+
lines << "- Missing FK indexes: #{filter_items(all_sections[:indexes], model).size}"
|
|
81
|
+
lines << "- Model.all in controllers: #{filter_items(all_sections[:model_all], model).size}"
|
|
82
|
+
lines << "- Eager load candidates: #{filter_items(all_sections[:eager_load], model).size}"
|
|
66
83
|
else
|
|
67
84
|
if category == "all" || category == "n_plus_one"
|
|
68
85
|
lines.concat(render_section("N+1 Query Risks", data[:n_plus_one_risks], model, detail))
|
|
@@ -81,8 +98,8 @@ module RailsAiContext
|
|
|
81
98
|
end
|
|
82
99
|
end
|
|
83
100
|
|
|
84
|
-
if
|
|
85
|
-
lines << "No performance issues detected. Your app looks good!"
|
|
101
|
+
if filtered_count == 0
|
|
102
|
+
lines << "No performance issues detected#{model && !model.empty? ? " for #{model}" : ""}. Your app looks good!"
|
|
86
103
|
end
|
|
87
104
|
|
|
88
105
|
text_response(lines.join("\n"))
|
|
@@ -91,28 +108,28 @@ module RailsAiContext
|
|
|
91
108
|
class << self
|
|
92
109
|
private
|
|
93
110
|
|
|
94
|
-
def
|
|
111
|
+
def filter_items(items, model_filter)
|
|
112
|
+
return (items || []) unless model_filter && !model_filter.empty?
|
|
95
113
|
return [] unless items&.any?
|
|
96
114
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
model_filter.underscore.pluralize.downcase
|
|
103
|
-
rescue
|
|
104
|
-
filter_lower
|
|
105
|
-
end
|
|
106
|
-
items.select { |i|
|
|
107
|
-
(i[:model]&.downcase == filter_lower) ||
|
|
108
|
-
(i[:table]&.downcase == table_form) ||
|
|
109
|
-
(i[:table]&.downcase == filter_lower) ||
|
|
110
|
-
(i[:table]&.downcase == model_filter.underscore.downcase)
|
|
111
|
-
}
|
|
112
|
-
else
|
|
113
|
-
items
|
|
115
|
+
filter_lower = model_filter.downcase
|
|
116
|
+
table_form = begin
|
|
117
|
+
model_filter.underscore.pluralize.downcase
|
|
118
|
+
rescue
|
|
119
|
+
filter_lower
|
|
114
120
|
end
|
|
121
|
+
items.select { |i|
|
|
122
|
+
(i[:model]&.downcase == filter_lower) ||
|
|
123
|
+
(i[:table]&.downcase == table_form) ||
|
|
124
|
+
(i[:table]&.downcase == filter_lower) ||
|
|
125
|
+
(i[:table]&.downcase == model_filter.underscore.downcase)
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def render_section(title, items, model_filter, detail)
|
|
130
|
+
return [] unless items&.any?
|
|
115
131
|
|
|
132
|
+
filtered = filter_items(items, model_filter)
|
|
116
133
|
return [] if filtered.empty?
|
|
117
134
|
|
|
118
135
|
lines = [ "## #{title} (#{filtered.size})", "" ]
|
|
@@ -44,9 +44,20 @@ module RailsAiContext
|
|
|
44
44
|
MULTI_STATEMENT = /;\s*\S/
|
|
45
45
|
ALLOWED_PREFIX = /\A\s*(SELECT|WITH|SHOW|EXPLAIN|DESCRIBE|DESC)\b/i
|
|
46
46
|
|
|
47
|
+
# SQL injection tautology patterns: OR 1=1, OR true, OR ''='', UNION SELECT, etc.
|
|
48
|
+
TAUTOLOGY_PATTERNS = [
|
|
49
|
+
/\bOR\s+1\s*=\s*1\b/i,
|
|
50
|
+
/\bOR\s+true\b/i,
|
|
51
|
+
/\bOR\s+'[^']*'\s*=\s*'[^']*'/i,
|
|
52
|
+
/\bOR\s+"[^"]*"\s*=\s*"[^"]*"/i,
|
|
53
|
+
/\bOR\s+\d+\s*=\s*\d+/i,
|
|
54
|
+
/\bUNION\s+(ALL\s+)?SELECT\b/i
|
|
55
|
+
].freeze
|
|
56
|
+
|
|
47
57
|
HARD_ROW_CAP = 1000
|
|
48
58
|
|
|
49
59
|
def self.call(sql: nil, limit: nil, format: "table", server_context: nil, **_extra)
|
|
60
|
+
set_call_params(sql: sql&.truncate(60))
|
|
50
61
|
# ── Environment guard ───────────────────────────────────────
|
|
51
62
|
unless config.allow_query_in_production || !Rails.env.production?
|
|
52
63
|
return text_response(
|
|
@@ -115,6 +126,10 @@ module RailsAiContext
|
|
|
115
126
|
return [ false, "Blocked: sensitive SHOW command" ] if cleaned.match?(BLOCKED_SHOWS)
|
|
116
127
|
return [ false, "Blocked: SELECT INTO creates a table" ] if cleaned.match?(SELECT_INTO)
|
|
117
128
|
|
|
129
|
+
# Check for SQL injection tautology patterns (OR 1=1, UNION SELECT, etc.)
|
|
130
|
+
tautology = TAUTOLOGY_PATTERNS.find { |p| cleaned.match?(p) }
|
|
131
|
+
return [ false, "Blocked: SQL injection pattern detected (#{cleaned[tautology]})" ] if tautology
|
|
132
|
+
|
|
118
133
|
# Check blocked keywords before the allowed-prefix fallback so that
|
|
119
134
|
# INSERT/UPDATE/DELETE/DROP etc. get a specific "Blocked" error
|
|
120
135
|
# rather than the generic "Only SELECT... allowed" message.
|
|
@@ -222,6 +237,15 @@ module RailsAiContext
|
|
|
222
237
|
# ── Column redaction (Layer 4) ──────────────────────────────────
|
|
223
238
|
private_class_method def self.redact_results(result)
|
|
224
239
|
redacted_cols = config.query_redacted_columns.map(&:downcase).to_set
|
|
240
|
+
|
|
241
|
+
# Auto-redact columns declared with `encrypts` in models
|
|
242
|
+
models_data = (SHARED_CACHE[:context] || cached_context)&.dig(:models)
|
|
243
|
+
if models_data.is_a?(Hash)
|
|
244
|
+
models_data.each_value do |data|
|
|
245
|
+
next unless data.is_a?(Hash)
|
|
246
|
+
(data[:encrypts] || []).each { |col| redacted_cols << col.to_s.downcase }
|
|
247
|
+
end
|
|
248
|
+
end
|
|
225
249
|
columns = result.columns
|
|
226
250
|
rows = result.rows
|
|
227
251
|
|
|
@@ -186,7 +186,7 @@ module RailsAiContext
|
|
|
186
186
|
result = GetModelDetails.call(model: model_name, detail: "standard")
|
|
187
187
|
text = result.content.first[:text]
|
|
188
188
|
lines << "" << "**Model context:** #{model_name}" unless text.include?("not found")
|
|
189
|
-
rescue; end
|
|
189
|
+
rescue => e; $stderr.puts "[rails-ai-context] Context lookup skipped: #{e.message}"; end
|
|
190
190
|
|
|
191
191
|
when :controller
|
|
192
192
|
ctrl_name = File.basename(file, ".rb").camelize
|
|
@@ -195,7 +195,7 @@ module RailsAiContext
|
|
|
195
195
|
result = GetRoutes.call(controller: snake, detail: "summary")
|
|
196
196
|
text = result.content.first[:text]
|
|
197
197
|
lines << "" << "**Routes:**" << text unless text.include?("not found") || text.include?("No routes")
|
|
198
|
-
rescue; end
|
|
198
|
+
rescue => e; $stderr.puts "[rails-ai-context] Context lookup skipped: #{e.message}"; end
|
|
199
199
|
|
|
200
200
|
when :migration
|
|
201
201
|
# Parse migration for table/column info
|
|
@@ -211,7 +211,7 @@ module RailsAiContext
|
|
|
211
211
|
result = GetSchema.call(table: t, detail: "summary")
|
|
212
212
|
text = result.content.first[:text]
|
|
213
213
|
lines << " #{t}: #{text.lines.first&.strip}" unless text.include?("not found")
|
|
214
|
-
rescue; end
|
|
214
|
+
rescue => e; $stderr.puts "[rails-ai-context] Context lookup skipped: #{e.message}"; end
|
|
215
215
|
end
|
|
216
216
|
end
|
|
217
217
|
end
|
|
@@ -221,7 +221,7 @@ module RailsAiContext
|
|
|
221
221
|
begin
|
|
222
222
|
result = GetRoutes.call(detail: "summary")
|
|
223
223
|
lines << "" << "**Current routes:** #{result.content.first[:text].lines.first&.strip}"
|
|
224
|
-
rescue; end
|
|
224
|
+
rescue => e; $stderr.puts "[rails-ai-context] Context lookup skipped: #{e.message}"; end
|
|
225
225
|
end
|
|
226
226
|
|
|
227
227
|
lines << ""
|
|
@@ -280,14 +280,15 @@ module RailsAiContext
|
|
|
280
280
|
end
|
|
281
281
|
|
|
282
282
|
# Check for controller changes without test changes
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
test_name =
|
|
287
|
-
spec_name =
|
|
288
|
-
request_name =
|
|
289
|
-
|
|
290
|
-
|
|
283
|
+
controller_files.each do |entry|
|
|
284
|
+
basename = File.basename(entry[:file], ".rb")
|
|
285
|
+
next unless basename.end_with?("_controller")
|
|
286
|
+
test_name = basename.sub("_controller", "_controller_test")
|
|
287
|
+
spec_name = basename.sub("_controller", "_controller_spec")
|
|
288
|
+
request_name = basename.sub("_controller", "_spec")
|
|
289
|
+
ctrl_stem = basename.delete_suffix("_controller")
|
|
290
|
+
unless test_files.any? { |t| File.basename(t[:file], ".rb").then { |tb| tb == test_name || tb == spec_name || tb == request_name || tb.include?(ctrl_stem) } }
|
|
291
|
+
warnings << "**No test changes**: `#{entry[:file]}` was modified but no corresponding test file was changed"
|
|
291
292
|
end
|
|
292
293
|
end
|
|
293
294
|
|
|
@@ -72,10 +72,12 @@ module RailsAiContext
|
|
|
72
72
|
lines << "|------|--------|------|"
|
|
73
73
|
|
|
74
74
|
queries.sort_by { |q| q[:timestamp] }.each do |q|
|
|
75
|
-
ago = time_ago(q[:timestamp])
|
|
75
|
+
ago = time_ago(q[:last_timestamp] || q[:timestamp])
|
|
76
76
|
params_str = q[:params].is_a?(Hash) ? q[:params].map { |k, v| "#{k}:#{v}" }.join(", ") : q[:params].to_s
|
|
77
77
|
params_display = params_str.empty? ? "-" : params_str.truncate(40)
|
|
78
|
-
|
|
78
|
+
count = q[:call_count] || 1
|
|
79
|
+
count_display = count > 1 ? " (#{count}x)" : ""
|
|
80
|
+
lines << "| `#{q[:tool]}`#{count_display} | #{params_display} | #{ago} |"
|
|
79
81
|
end
|
|
80
82
|
|
|
81
83
|
lines << ""
|
|
@@ -90,22 +92,25 @@ module RailsAiContext
|
|
|
90
92
|
return text_response("No queries recorded yet.")
|
|
91
93
|
end
|
|
92
94
|
|
|
95
|
+
total_calls = queries.sum { |q| q[:call_count] || 1 }
|
|
96
|
+
unique_tools = queries.map { |q| q[:tool] }.uniq.size
|
|
93
97
|
lines = [ "# Session Summary", "" ]
|
|
94
|
-
lines << "You have
|
|
98
|
+
lines << "You have made #{total_calls} tool call(s) across #{unique_tools} unique tool(s) in this session:"
|
|
95
99
|
lines << ""
|
|
96
100
|
|
|
97
|
-
# Group by tool name
|
|
101
|
+
# Group by tool name, summing actual call counts
|
|
98
102
|
by_tool = queries.group_by { |q| q[:tool] }
|
|
99
|
-
by_tool.each do |tool,
|
|
100
|
-
|
|
103
|
+
by_tool.each do |tool, entries|
|
|
104
|
+
total_calls = entries.sum { |e| e[:call_count] || 1 }
|
|
105
|
+
params_list = entries.map { |c|
|
|
101
106
|
p = c[:params]
|
|
102
107
|
p.is_a?(Hash) ? p.map { |k, v| "#{k}:#{v}" }.join(", ") : p.to_s
|
|
103
108
|
}.reject(&:empty?)
|
|
104
109
|
|
|
105
110
|
if params_list.any?
|
|
106
|
-
lines << "- **#{tool}** (#{
|
|
111
|
+
lines << "- **#{tool}** (#{total_calls}x): #{params_list.uniq.join('; ')}"
|
|
107
112
|
else
|
|
108
|
-
lines << "- **#{tool}** (#{
|
|
113
|
+
lines << "- **#{tool}** (#{total_calls}x)"
|
|
109
114
|
end
|
|
110
115
|
end
|
|
111
116
|
|