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.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/CLAUDE.md +1 -1
  4. data/lib/rails_ai_context/introspectors/performance_introspector.rb +13 -16
  5. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +3 -3
  6. data/lib/rails_ai_context/serializers/claude_serializer.rb +3 -3
  7. data/lib/rails_ai_context/serializers/context_file_serializer.rb +2 -1
  8. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +3 -3
  9. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +4 -4
  10. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +3 -3
  11. data/lib/rails_ai_context/tools/analyze_feature.rb +24 -1
  12. data/lib/rails_ai_context/tools/base_tool.rb +22 -7
  13. data/lib/rails_ai_context/tools/dependency_graph.rb +4 -1
  14. data/lib/rails_ai_context/tools/diagnose.rb +5 -5
  15. data/lib/rails_ai_context/tools/generate_test.rb +32 -4
  16. data/lib/rails_ai_context/tools/get_context.rb +1 -0
  17. data/lib/rails_ai_context/tools/get_model_details.rb +1 -0
  18. data/lib/rails_ai_context/tools/get_partial_interface.rb +11 -4
  19. data/lib/rails_ai_context/tools/get_schema.rb +1 -0
  20. data/lib/rails_ai_context/tools/migration_advisor.rb +6 -3
  21. data/lib/rails_ai_context/tools/onboard.rb +105 -21
  22. data/lib/rails_ai_context/tools/performance_check.rb +45 -28
  23. data/lib/rails_ai_context/tools/query.rb +24 -0
  24. data/lib/rails_ai_context/tools/review_changes.rb +13 -12
  25. data/lib/rails_ai_context/tools/session_context.rb +13 -8
  26. data/lib/rails_ai_context/tools/validate.rb +5 -0
  27. data/lib/rails_ai_context/version.rb +1 -1
  28. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ba5dfa48fbbc7efaa498640785dd290523f434ed1f7244849f31046f8359e62
4
- data.tar.gz: 2dca436c86b470b51b38e745a47c64ee3908b7e342677c842940b9515dcf57f2
3
+ metadata.gz: 5081505ca1d99874de52ff33fb72ff75877b67ec79984fb9bb9004dbcbf4ea27
4
+ data.tar.gz: cc6781e87f54708aab290044a33aee7aff3ddb7579fd4fe4f592eb5d3721b310
5
5
  SHA512:
6
- metadata.gz: 64c07284bc9d61136ff8dee427396fd0237fc628e9ca8cd08524022a7d9031521360b49526a93205cb6c3990284e9096aedead5d88d2fc0d75ad2f58475d30c6
7
- data.tar.gz: d82fbfea6afbe2e7c44f22afa1b8f4201fbb6cf9fda17362675f46000e911bece69a3384133b19bc30d5ffe3c553e4cbb935cbd6031df92f21c6dfc3eb32aa78
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
@@ -60,7 +60,7 @@ structure to AI assistants via the Model Context Protocol (MCP).
60
60
  ## Testing
61
61
 
62
62
  ```bash
63
- bundle exec rspec # Run specs (1170 examples)
63
+ bundle exec rspec # Run specs (1529 examples)
64
64
  bundle exec rubocop # Lint
65
65
  ```
66
66
 
@@ -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 accessing association
124
- view_pattern_found = check_views_for_association_access(model_ref, assoc[:name])
125
- next unless view_pattern_found
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 check_views_for_association_access(model_name, association_name)
141
+ def preload_view_contents
143
142
  views_dir = File.join(root, "app/views")
144
- return false unless Dir.exist?(views_dir)
143
+ return [] unless Dir.exist?(views_dir)
145
144
 
146
- # Check if any view iterates over the association
147
- Dir.glob(File.join(views_dir, "**/*.{erb,haml,slim}")).any? do |path|
148
- content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
149
- content.match?(/\.#{Regexp.escape(association_name)}\b/)
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
- tool: tool_name.to_s,
70
- params: params,
71
- timestamp: Time.now.iso8601,
72
- summary: 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
- session_record(tool_name, {}, summary)
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("model", model, graph.keys, recovery_tool: "rails_dependency_graph")
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 = fixture_names.keys.find { |k| k.to_s.downcase == name.underscore.pluralize } ? ":one" : ":one"
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(:one)"
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
- lines << " #{param} = #{snake.pluralize}(:one).id"
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
- lines << " #{param} = #{resource.pluralize}(:one).id"
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
- partial.delete_prefix("_").sub(/\..*\z/, ""), # shared/status_badge
345
- basename # status_badge
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
- next unless line.match?(/render\s.*["']#{Regexp.escape(search_name)}["']/)
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
- parts = [ action.camelize, column&.camelize, "To", table.camelize ].compact
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 << " # Specify the original type here"
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
- if authentication[:method]
194
- lines << "Authentication is handled by #{authentication[:method]}."
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
- if authentication[:model]
197
- lines << "The #{authentication[:model]} model handles user accounts."
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
- if authorization[:method]
200
- lines << "Authorization uses #{authorization[:method]}."
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
- return [] unless (turbo.is_a?(Hash) && !turbo[:error]) || channels.any?
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
- if turbo.is_a?(Hash) && !turbo[:error] && turbo[:broadcasts]&.any?
367
- lines << "Turbo Stream broadcasts: #{turbo[:broadcasts].size} broadcast points."
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
- lines << "Dockerfile: #{devops[:dockerfile] ? 'present' : 'not found'}."
414
- lines << "Procfile: #{devops[:procfile] ? 'present' : 'not found'}." if devops.key?(:procfile)
415
- deploy = devops[:deployment_method]
416
- lines << "Deployment: #{deploy}." if deploy
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("model", model, model_names, recovery_tool: "rails_performance_check")
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
- summary = data[:summary] || {}
57
- lines << "**Total issues found:** #{summary[:total_issues] || 0}"
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: #{summary[:n_plus_one_risks] || 0}"
62
- lines << "- Missing counter_cache: #{summary[:missing_counter_cache] || 0}"
63
- lines << "- Missing FK indexes: #{summary[:missing_fk_indexes] || 0}"
64
- lines << "- Model.all in controllers: #{summary[:model_all_in_controllers] || 0}"
65
- lines << "- Eager load candidates: #{summary[:eager_load_candidates] || 0}"
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 summary[:total_issues] == 0
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 render_section(title, items, model_filter, detail)
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
- filtered = if model_filter
98
- filter_lower = model_filter.downcase
99
- # Underscore BEFORE downcase to handle CamelCase → snake_case correctly
100
- # "BrandProfile" → "brand_profile" → "brand_profiles"
101
- table_form = begin
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
- changed_ctrls = controller_files.map { |c| File.basename(c[:file], ".rb") }
284
- changed_tests = test_files.map { |t| File.basename(t[:file], ".rb") }
285
- changed_ctrls.each do |ctrl|
286
- test_name = ctrl.sub("_controller", "_controller_test")
287
- spec_name = ctrl.sub("_controller", "_controller_spec")
288
- request_name = ctrl.sub("_controller", "_spec")
289
- unless changed_tests.any? { |t| t == test_name || t == spec_name || t == request_name || t.include?(ctrl.delete_suffix("_controller")) }
290
- warnings << "**No test changes**: `#{ctrl}.rb` was modified but no corresponding test file was changed"
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
- lines << "| `#{q[:tool]}` | #{params_display} | #{ago} |"
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 queried #{queries.size} tool(s) in this session:"
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, calls|
100
- params_list = calls.map { |c|
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}** (#{calls.size}x): #{params_list.uniq.join('; ')}"
111
+ lines << "- **#{tool}** (#{total_calls}x): #{params_list.uniq.join('; ')}"
107
112
  else
108
- lines << "- **#{tool}** (#{calls.size}x)"
113
+ lines << "- **#{tool}** (#{total_calls}x)"
109
114
  end
110
115
  end
111
116
 
@@ -48,6 +48,11 @@ module RailsAiContext
48
48
  total = 0
49
49
 
50
50
  files.each do |file|
51
+ if file.nil? || file.strip.empty?
52
+ results << "- (empty) \u2014 skipped (empty filename)"
53
+ next
54
+ end
55
+
51
56
  full_path = Rails.root.join(file)
52
57
 
53
58
  unless File.exist?(full_path)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "4.3.1"
4
+ VERSION = "4.3.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-ai-context
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.3.1
4
+ version: 4.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine