rails-ai-context 5.32.1 → 5.32.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/CONTEXT.md +10 -8
  4. data/CONTRIBUTING.md +2 -2
  5. data/README.md +14 -11
  6. data/SECURITY.md +4 -4
  7. data/docs/ARCHITECTURE.md +11 -10
  8. data/docs/CLI.md +33 -7
  9. data/docs/COMPATIBILITY.md +48 -32
  10. data/docs/CONFIGURATION.md +10 -8
  11. data/docs/CUSTOM_TOOLS.md +8 -8
  12. data/docs/FAQ.md +6 -5
  13. data/docs/GUIDE.md +154 -80
  14. data/docs/INTROSPECTORS.md +24 -16
  15. data/docs/QUICKSTART.md +14 -3
  16. data/docs/RECIPES.md +50 -40
  17. data/docs/SECURITY.md +23 -13
  18. data/docs/SETUP.md +23 -26
  19. data/docs/STANDALONE.md +7 -3
  20. data/docs/TOOLS.md +79 -45
  21. data/docs/TROUBLESHOOTING.md +12 -11
  22. data/exe/rails-ai-context +8 -4
  23. data/lib/generators/rails_ai_context/install/install_generator.rb +1 -1
  24. data/lib/rails_ai_context/configuration.rb +2 -2
  25. data/lib/rails_ai_context/install/ai_tool.rb +11 -2
  26. data/lib/rails_ai_context/install/cleanup.rb +69 -23
  27. data/lib/rails_ai_context/install/program.rb +4 -1
  28. data/lib/rails_ai_context/install/selection_record.rb +20 -2
  29. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +2 -2
  30. data/lib/rails_ai_context/server.rb +2 -2
  31. data/lib/rails_ai_context/tasks/rails_ai_context.rake +12 -4
  32. data/lib/rails_ai_context/test_helper.rb +2 -0
  33. data/lib/rails_ai_context/tools/base_tool.rb +3 -1
  34. data/lib/rails_ai_context/tools/dependency_graph.rb +2 -2
  35. data/lib/rails_ai_context/tools/get_concern.rb +1 -1
  36. data/lib/rails_ai_context/tools/get_env.rb +1 -1
  37. data/lib/rails_ai_context/tools/get_gems.rb +2 -2
  38. data/lib/rails_ai_context/tools/query.rb +107 -39
  39. data/lib/rails_ai_context/tools/search_code.rb +20 -16
  40. data/lib/rails_ai_context/version.rb +1 -1
  41. data/server.json +1 -1
  42. metadata +1 -1
@@ -11,49 +11,95 @@ module RailsAiContext
11
11
  module Cleanup
12
12
  module_function
13
13
 
14
+ # The block SectionMarkerWriter manages inside a root context file.
15
+ BEGIN_MARKER = "<!-- BEGIN rails-ai-context -->"
16
+ MANAGED_BLOCK = /#{BEGIN_MARKER}.*?<!-- END rails-ai-context -->(?:\r?\n)?/m
17
+
18
+ # The line a split rules file generated whole carries.
19
+ GENERATED_NOTE = "Auto-generated by rails-ai-context. Do not edit manually."
20
+ GENERATED_LINE = /^> #{Regexp.escape(GENERATED_NOTE)}\r?$/
21
+
22
+ # Removes what this gem generated and nothing the user wrote: the named
23
+ # rule files inside a rules directory, the managed block inside a root
24
+ # file, and a file generated whole.
25
+ #
14
26
  # @param tools [Array<Symbol>] the AI tools being dropped
15
27
  # @param keeping [Array<Symbol>] the tools still selected, whose files
16
28
  # must survive even when a dropped tool names the same path
17
29
  # @param root [String, Pathname] project root
18
- # @return [Hash] { removed: [paths], failed: [paths] }, directories
19
- # marked with a trailing slash so callers can print them the way they
20
- # always have. Both rm calls swallow their errors, so a path is only
30
+ # @return [Hash] { removed: [paths], trimmed: [paths], kept: [paths], failed: [paths] }.
31
+ # A directory carries a trailing slash. `trimmed` files lost their
32
+ # managed block and kept the user's lines; `kept` files hold nothing
33
+ # this gem generated. rm swallows its errors, so a path is only
21
34
  # reported removed once it is gone.
22
35
  def remove(tools:, keeping:, root:)
23
36
  kept = Array(keeping).flat_map { |key| AiTool.find(key)&.context_paths || [] }.to_set
24
- removed = []
25
- failed = []
37
+ outcome = { removed: [], trimmed: [], kept: [], failed: [] }
26
38
 
27
39
  Array(tools).each do |key|
28
- paths = AiTool.find(key)&.context_paths || []
29
- paths.each do |relative|
40
+ tool = AiTool.find(key) or next
41
+ tool.context_paths.each do |relative|
30
42
  next if kept.include?(relative)
31
43
 
32
- label, gone = remove_path(relative, root)
33
- next unless label
34
-
35
- (gone ? removed : failed) << label
44
+ if File.directory?(File.join(root.to_s, relative))
45
+ remove_rule_files(tool, relative, root, outcome)
46
+ else
47
+ remove_generated(relative, root, outcome)
48
+ end
36
49
  end
37
50
  end
38
51
 
39
- { removed: removed, failed: failed }
52
+ outcome
40
53
  end
41
54
 
42
- # @return [Array(String, Boolean), nil] the path's label and whether it
43
- # is gone, or nil when there was nothing there to remove. A directory
44
- # whose children went but which itself survives counts as not gone.
45
- def remove_path(relative, root)
55
+ # A symlink is somebody's own arrangement, and writing through one edits
56
+ # a file this run was not asked about.
57
+ def remove_rule_files(tool, dir, root, outcome)
58
+ full = File.join(root.to_s, dir)
59
+ return if File.symlink?(full)
60
+
61
+ names = Array(tool.rule_files) + tool.legacy_paths.select { |path| File.dirname(path) == dir }.map { |path| File.basename(path) }
62
+ names.each { |name| delete(File.join(dir, name), root, outcome) }
63
+ return unless Dir.empty?(full)
64
+
65
+ Dir.rmdir(full)
66
+ outcome[:removed] << "#{dir}/"
67
+ rescue SystemCallError
68
+ outcome[:failed] << "#{dir}/"
69
+ end
70
+ private_class_method :remove_rule_files
71
+
72
+ # Bytes in, bytes out: the markers are ASCII, and the user's lines may not be valid UTF-8.
73
+ def remove_generated(relative, root, outcome)
46
74
  full = File.join(root.to_s, relative)
75
+ return unless File.file?(full) || File.symlink?(full)
76
+ return outcome[:kept] << relative if File.symlink?(full)
47
77
 
48
- if File.directory?(full)
49
- FileUtils.rm_rf(full)
50
- [ "#{relative}/", !File.exist?(full) ]
51
- elsif File.exist?(full)
52
- FileUtils.rm_f(full)
53
- [ relative, !File.exist?(full) ]
78
+ content = File.binread(full)
79
+ rest = content.gsub(MANAGED_BLOCK, "")
80
+ # The writer puts one blank line under a block it prepends to a file that was already there.
81
+ rest = rest.sub(/\A\r?\n/, "") if content.start_with?(BEGIN_MARKER)
82
+ if rest == content
83
+ content.match?(GENERATED_LINE) ? delete(relative, root, outcome) : outcome[:kept] << relative
84
+ elsif rest.strip.empty?
85
+ delete(relative, root, outcome)
86
+ else
87
+ File.binwrite(full, rest)
88
+ outcome[:trimmed] << relative
54
89
  end
90
+ rescue SystemCallError
91
+ outcome[:failed] << relative
92
+ end
93
+ private_class_method :remove_generated
94
+
95
+ def delete(relative, root, outcome)
96
+ full = File.join(root.to_s, relative)
97
+ return unless File.file?(full) && !File.symlink?(full)
98
+
99
+ FileUtils.rm_f(full)
100
+ (File.exist?(full) ? outcome[:failed] : outcome[:removed]) << relative
55
101
  end
56
- private_class_method :remove_path
102
+ private_class_method :delete
57
103
  end
58
104
  end
59
105
  end
@@ -113,13 +113,16 @@ module RailsAiContext
113
113
 
114
114
  outcome = Cleanup.remove(tools: [ key ], keeping: selected.map(&:to_sym), root: root)
115
115
  outcome[:removed].each { |path| surface.say " Removed #{path}", :ok }
116
+ outcome[:trimmed].each { |path| surface.say " Removed the generated section from #{path}", :ok }
117
+ outcome[:kept].each { |path| surface.say " Kept #{path} (nothing in it was generated by rails-ai-context)", :muted }
116
118
  outcome[:failed].each { |path| surface.say "Could not remove #{path} - check its permissions", :warn }
117
119
 
118
120
  # Merge-safe MCP config cleanup - removes only the rails-ai-context entry
119
121
  cleaned = RailsAiContext::McpConfigGenerator.remove(tools: [ key ], output_dir: root.to_s)
120
122
  cleaned.each { |f| surface.say " Removed MCP entry from #{relative_to(f, root)}", :ok }
121
123
 
122
- surface.say " #{tool.name} files removed", :ok if tool && outcome[:failed].empty?
124
+ gone = outcome[:removed].any? || outcome[:trimmed].any? || cleaned.any?
125
+ surface.say " #{tool.name} files removed", :ok if tool && gone && outcome[:failed].empty?
123
126
  end
124
127
  end
125
128
 
@@ -74,7 +74,7 @@ module RailsAiContext
74
74
  recorded.nil? ? nil : !!recorded
75
75
  end
76
76
 
77
- # @return [Symbol] :updated, :inserted, :unchanged or :absent
77
+ # @return [Symbol] :updated, :inserted, :unchanged, :conflict or :absent
78
78
  def write_context_files(value, root:)
79
79
  write_config_line(root, " config.context_files = #{value ? 'true' : 'false'}",
80
80
  CONTEXT_FILES_LINE, /^[ \t]*config\.context_files\s*=.*$/)
@@ -83,7 +83,7 @@ module RailsAiContext
83
83
  # Only an uncommented line is rewritten - the generated initializer ships
84
84
  # a commented-out default that must stay a comment.
85
85
  #
86
- # @return [Symbol] :updated, :inserted, :unchanged or :absent
86
+ # @return [Symbol] :updated, :inserted, :unchanged, :conflict or :absent
87
87
  def write_tool_mode(mode, root:)
88
88
  write_config_line(root, " config.tool_mode = :#{mode}",
89
89
  MODE_LINE, /^[ \t]*config\.tool_mode\s*=.*$/)
@@ -134,6 +134,20 @@ module RailsAiContext
134
134
  write((read(root: root) || []) | addition, root: root)
135
135
  end
136
136
 
137
+ # What to say when a single-value key came back :conflict, nil otherwise.
138
+ def conflict_message(key, status)
139
+ return unless status == :conflict
140
+
141
+ [ :warn, "#{INITIALIZER} sets config.#{key} in a form this installer does not " \
142
+ "rewrite, and it takes precedence - edit it by hand to change it" ]
143
+ end
144
+
145
+ # Whether the app has settled its tool mode, in the record or in an
146
+ # initializer line of any shape, so no entry asks again.
147
+ def tool_mode_set?(root:)
148
+ !tool_mode(root: root).nil? || initializer_content(root).to_s.match?(/^[ \t]*config\.tool_mode\s*=/)
149
+ end
150
+
137
151
  def initializer_line(tools)
138
152
  " config.ai_tools = %i[#{normalize(tools).join(' ')}]"
139
153
  end
@@ -196,6 +210,10 @@ module RailsAiContext
196
210
 
197
211
  File.write(path, updated)
198
212
  :updated
213
+ elsif content.match?(assignment)
214
+ # Assigned in a shape this module will not rewrite. A second line
215
+ # beside it would lose at boot and win on read.
216
+ :conflict
199
217
  elsif content.match?(SELECTION_LINE)
200
218
  File.write(path, content.sub(/^([ \t]*config\.ai_tools\s*=[^\n]*)$/) { "#{Regexp.last_match(1)}\n#{line}" })
201
219
  :inserted
@@ -45,7 +45,7 @@ module RailsAiContext
45
45
  lines = [
46
46
  "# ActiveRecord Models (#{models.size})",
47
47
  "",
48
- "> Auto-generated by rails-ai-context. Do not edit manually.",
48
+ "> #{Install::Cleanup::GENERATED_NOTE}",
49
49
  "> Check here first for scopes, constants, associations. Read model files for business logic/methods.",
50
50
  ""
51
51
  ]
@@ -87,7 +87,7 @@ module RailsAiContext
87
87
  lines = [
88
88
  "# Controllers (#{app_controllers.size})",
89
89
  "",
90
- "> Auto-generated by rails-ai-context. Do not edit manually.",
90
+ "> #{Install::Cleanup::GENERATED_NOTE}",
91
91
  "> Read controller files directly when editing. Use MCP tools for reference only.",
92
92
  ""
93
93
  ]
@@ -274,7 +274,7 @@ module RailsAiContext
274
274
  end
275
275
 
276
276
  # Conditionally start live reload based on configuration.
277
- # :auto - try to load `listen`, skip silently with a tip if missing
277
+ # :auto - try to load `listen`, print a tip to stderr if missing
278
278
  # true - try to load `listen`, raise if missing
279
279
  # false - skip entirely
280
280
  def maybe_start_live_reload(mcp_server)
@@ -291,7 +291,7 @@ module RailsAiContext
291
291
  raise LoadError, "Live reload requires the `listen` gem. Add to your Gemfile: gem 'listen', group: :development"
292
292
  end
293
293
 
294
- # :auto mode - skip silently with a tip
294
+ # :auto mode - skip with a tip
295
295
  $stderr.puts "[rails-ai-context] Live reload unavailable (add `listen` gem for auto-refresh)"
296
296
  end
297
297
  end
@@ -77,14 +77,19 @@ def prompt_setup
77
77
  RailsAiContext::Install::Program.select_setup(install_surface)
78
78
  end unless defined?(prompt_setup)
79
79
 
80
+ def say_conflict(key, status)
81
+ _level, text = RailsAiContext::Install::SelectionRecord.conflict_message(key, status)
82
+ puts "⚠️ #{text}" if text
83
+ end unless defined?(say_conflict)
84
+
80
85
  def save_tool_mode_to_initializer(mode)
81
- RailsAiContext::Install::SelectionRecord.write_tool_mode(mode, root: Rails.root)
86
+ say_conflict(:tool_mode, RailsAiContext::Install::SelectionRecord.write_tool_mode(mode, root: Rails.root))
82
87
  rescue => e
83
88
  RailsAiContext.debug_fail(e, nil, label: "save_tool_mode_to_initializer")
84
89
  end unless defined?(save_tool_mode_to_initializer)
85
90
 
86
91
  def save_context_files_to_initializer(value)
87
- RailsAiContext::Install::SelectionRecord.write_context_files(value, root: Rails.root)
92
+ say_conflict(:context_files, RailsAiContext::Install::SelectionRecord.write_context_files(value, root: Rails.root))
88
93
  rescue => e
89
94
  RailsAiContext.debug_fail(e, nil, label: "save_context_files_to_initializer")
90
95
  end unless defined?(save_context_files_to_initializer)
@@ -100,7 +105,7 @@ rescue => e
100
105
  end unless defined?(ensure_mcp_configs)
101
106
 
102
107
  def tool_mode_configured?
103
- !RailsAiContext::Install::SelectionRecord.tool_mode(root: Rails.root).nil?
108
+ RailsAiContext::Install::SelectionRecord.tool_mode_set?(root: Rails.root)
104
109
  rescue => e
105
110
  RailsAiContext.debug_fail(e, false, label: "tool_mode_configured?")
106
111
  end unless defined?(tool_mode_configured?)
@@ -295,7 +300,10 @@ namespace :ai do
295
300
  end
296
301
 
297
302
  namespace :context do
298
- per_tool = RailsAiContext::Install::AiTool.all.to_h { |tool| [ tool.key, tool.files ] }
303
+ # The MCP config a tool's file list names is the installer's to write, not this task's.
304
+ per_tool = RailsAiContext::Install::AiTool.all.to_h { |tool|
305
+ [ tool.key, tool.files.split(" + ").reject { |file| file == tool.mcp_config[:path] }.join(" + ") ]
306
+ }
299
307
  per_tool.merge(json: ".ai-context.json").each do |fmt, file|
300
308
  desc "Generate #{file} context file"
301
309
  task fmt => :environment do
@@ -4,6 +4,8 @@
4
4
  # Bundler.require never loads it in the test environment, so this file is
5
5
  # often the first (and only) entry point required from a test suite.
6
6
  require_relative "../rails_ai_context"
7
+ # A custom tool subclasses MCP::Tool, and nothing else has loaded the SDK yet.
8
+ require "mcp"
7
9
 
8
10
  module RailsAiContext
9
11
  # Reusable test helper for verifying MCP tools - both built-in and custom.
@@ -371,7 +371,9 @@ module RailsAiContext
371
371
  # the composers do not index into `content` themselves.
372
372
  def response_text(response)
373
373
  first = response.content.first
374
- first.is_a?(Hash) ? first[:text].to_s : ""
374
+ text = first.is_a?(Hash) ? first[:text].to_s : ""
375
+ # The composing tool's own response carries the tier banner, once.
376
+ (banner = static_tier_banner) ? text.gsub(banner, "") : text
375
377
  end
376
378
 
377
379
  # One-line banner listing introspectors that failed during context
@@ -4,8 +4,8 @@ module RailsAiContext
4
4
  module Tools
5
5
  class DependencyGraph < BaseTool
6
6
  tool_name "rails_dependency_graph"
7
- description "Generates a dependency graph showing how models, services, and controllers " \
8
- "connect. Output as Mermaid diagram syntax or plain text. " \
7
+ description "Generates a dependency graph showing how models connect through their " \
8
+ "associations. Output as Mermaid diagram syntax or plain text. " \
9
9
  "Use when: understanding feature architecture, tracing data flow, planning refactors. " \
10
10
  "Key params: model (center graph on model), depth (1-3), format (mermaid/text)."
11
11
 
@@ -18,7 +18,7 @@ module RailsAiContext
18
18
  type: "string",
19
19
  description: "Filter by concern type, the name each section heading of the listing prints: model for app/models/concerns/, mailer for app/mailers/concerns/, and for a concern outside every concerns directory the root it lives in (service for app/services, lib for lib). other: app/concerns itself, or a configured directory. all: everything (default)."
20
20
  },
21
- detail: RailsAiContext::DetailLevel.schema("Detail level. summary: concern names only. standard: names + method signatures (default). full: method signatures with source code.")
21
+ detail: RailsAiContext::DetailLevel.schema("Detail level, read when `name` is given. summary and standard (default): method signatures. full: method signatures with source code.")
22
22
  }
23
23
  )
24
24
 
@@ -426,7 +426,7 @@ module RailsAiContext
426
426
  end
427
427
 
428
428
  private_class_method def self.scan_env_example(root)
429
- # Only read .env.example or .env.sample - NEVER .env or .env.local
429
+ # Only the example files - NEVER .env or .env.local
430
430
  candidates = %w[.env.example .env.sample .env.template]
431
431
  vars = []
432
432
 
@@ -4,7 +4,7 @@ module RailsAiContext
4
4
  module Tools
5
5
  class GetGems < BaseTool
6
6
  tool_name "rails_get_gems"
7
- description "Get notable gems from Gemfile.lock grouped by category: auth, jobs, frontend, API, database, testing, deploy. " \
7
+ description "Get notable gems from Gemfile.lock grouped by category (auth, jobs, frontend, API, database, testing, deploy and more). " \
8
8
  "Use when: checking what libraries are available before adding a dependency, or understanding the tech stack. " \
9
9
  "Filter with category:\"auth\" or category:\"database\". Omit for all categories."
10
10
 
@@ -12,7 +12,7 @@ module RailsAiContext
12
12
  properties: {
13
13
  category: {
14
14
  type: "string",
15
- enum: %w[auth jobs frontend api database files testing deploy all],
15
+ enum: Introspectors::GemIntrospector::NOTABLE_GEMS.values.map { |gem| gem[:category].to_s }.uniq + %w[all],
16
16
  description: "Filter by category. Default: all."
17
17
  },
18
18
  offset: {
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "strscan"
4
+
3
5
  module RailsAiContext
4
6
  module Tools
5
7
  class Query < BaseTool
@@ -173,12 +175,17 @@ module RailsAiContext
173
175
  end
174
176
 
175
177
  # ── Layer 1: SQL validation ─────────────────────────────────
176
- valid, error = validate_sql(sql)
178
+ mysql = mysql_dialect?
179
+ valid, error = validate_sql(sql, mysql: mysql)
177
180
  return error_response(error) unless valid
178
181
 
182
+ # Run the text the validator read, never the raw input: whatever the
183
+ # scanner and the database disagree on, nothing unread reaches the database.
184
+ sql = comment_free(sql, mysql: mysql)
185
+
179
186
  # ── EXPLAIN mode ────────────────────────────────────────────
180
187
  if explain
181
- return execute_explain(sql.strip, config.query_timeout)
188
+ return execute_explain(sql, config.query_timeout)
182
189
  end
183
190
 
184
191
  # Resolve row limit
@@ -187,11 +194,11 @@ module RailsAiContext
187
194
  timeout_seconds = config.query_timeout
188
195
 
189
196
  # ── Layers 2-3: Execute with DB-level safety + row limit ────
190
- result = execute_safely(sql.strip, row_limit, timeout_seconds)
197
+ result = execute_safely(sql, row_limit, timeout_seconds)
191
198
 
192
199
  # ── Layer 4: Redact sensitive columns ───────────────────────
193
200
  # Skip for SHOW/DESCRIBE/EXPLAIN - see SCHEMA_METADATA_PREFIX.
194
- redacted = sql.strip.match?(SCHEMA_METADATA_PREFIX) ? result : redact_results(result)
201
+ redacted = sql.match?(SCHEMA_METADATA_PREFIX) ? result : redact_results(result)
195
202
 
196
203
  # ── Format output ───────────────────────────────────────────
197
204
  output = case format
@@ -227,36 +234,73 @@ module RailsAiContext
227
234
  end
228
235
 
229
236
  # ── SQL comment stripping ───────────────────────────────────────
230
- def self.strip_sql_comments(sql)
231
- sql
232
- # MySQL version-conditional comments `/*! ... */` / `/*!12345 ... */`
233
- # are NOT comments - MySQL executes their content. Unwrap them first
234
- # so the validator sees the inner SQL (e.g. `LOAD_FILE`) instead of
235
- # stripping it alongside regular block comments, which would let
236
- # `SELECT /*!50000 LOAD_FILE('/etc/passwd') */` slip past
237
- # BLOCKED_FUNCTIONS. PostgreSQL and SQLite treat `/*! ... */` as
238
- # a normal comment, so unwrapping is MySQL-safe: the content is
239
- # ignored by those engines anyway.
240
- .gsub(/\/\*!\d*\s*(.*?)\*\//m) { " #{Regexp.last_match(1)} " }
241
- .gsub(/\/\*.*?\*\//m, " ") # Block comments: /* ... */
242
- .gsub(/--[^\n]*/, " ") # Line comments: -- ...
243
- .gsub(/^\s*#[^\n]*/m, " ") # MySQL-style comments: # at line start only
244
- .squeeze(" ").strip
237
+ def self.strip_sql_comments(sql, mysql: false)
238
+ comment_free(sql, mysql: mysql).squeeze(" ")
239
+ end
240
+
241
+ QUOTED = /'[^']*'?|"[^"]*"?|`[^`]*`?/m
242
+ # A backslash escapes the next character: every MySQL string, and a PostgreSQL E'...' one.
243
+ ESCAPED_STRING = /'(?:\\.|[^'\\])*'?/m
244
+ MYSQL_QUOTED = /#{ESCAPED_STRING}|"(?:\\.|[^"\\])*"?|`[^`]*`?/m
245
+ DOLLAR_QUOTED = /(?<![\p{Word}$])\$(\p{Word}*)\$.*?\$\1\$/m
246
+ UNQUOTED = /[^'"`$\/\-#*]+/
247
+
248
+ # Comments out, everything else as written: this is the text that runs.
249
+ # A quoted span is copied whole, so a comment marker inside one is data.
250
+ # Only MySQL has a hash comment, a `--` that needs a space after it, and
251
+ # `/*! ... */` content it runs. PostgreSQL has dollar quoting.
252
+ def self.comment_free(sql, mysql: false)
253
+ # fixed_anchor, so the dollar quote's lookbehind sees the text before the pointer.
254
+ scanner = StringScanner.new(sql, fixed_anchor: true)
255
+ line_comment = mysql ? /--(?=\s|\z)[^\n]*|#[^\n]*/ : /--[^\n]*/
256
+ out = +""
257
+ executable = false
258
+
259
+ until scanner.eos?
260
+ quoted = if mysql then MYSQL_QUOTED
261
+ elsif out.match?(/(?<![\p{Word}$])[eE]\z/) && scanner.check(/'/) then ESCAPED_STRING
262
+ else QUOTED
263
+ end
264
+
265
+ if (span = scanner.scan(quoted) || (!mysql && scanner.scan(DOLLAR_QUOTED)) || scanner.scan(UNQUOTED))
266
+ out << span
267
+ elsif mysql && scanner.scan(/\/\*!\d*/)
268
+ # Its content stays in the text for the checks to read, e.g. the
269
+ # `LOAD_FILE` in `SELECT /*!50000 LOAD_FILE('/etc/passwd') */`.
270
+ executable = true
271
+ out << " "
272
+ elsif executable && scanner.scan(/\*\//)
273
+ executable = false
274
+ out << " "
275
+ elsif scanner.scan(/\/\*.*?(?:\*\/|\z)/m) || scanner.scan(line_comment)
276
+ out << " "
277
+ else
278
+ out << scanner.getch
279
+ end
280
+ end
281
+
282
+ out.strip
283
+ end
284
+
285
+ private_class_method def self.mysql_dialect?
286
+ ActiveRecord::Base.connection_db_config.adapter.to_s.match?(MYSQL_ADAPTER)
287
+ rescue ActiveRecord::ActiveRecordError
288
+ false
245
289
  end
246
290
 
247
291
  # ── SQL validation (Layer 1) ────────────────────────────────────
248
- def self.validate_sql(sql)
292
+ def self.validate_sql(sql, mysql: false)
249
293
  return [ false, "SQL query is required." ] if sql.nil? || sql.strip.empty?
250
294
 
251
295
  # Belt-and-suspenders: run BLOCKED_FUNCTIONS against the RAW sql before
252
- # stripping comments. If the unwrap logic in strip_sql_comments is ever
296
+ # stripping comments. If the unwrap logic in comment_free is ever
253
297
  # defeated by a novel MySQL comment variant, this still catches the
254
298
  # dangerous primitives (pg_read_file, LOAD_FILE, dblink, ...).
255
299
  if (m = sql.match(BLOCKED_FUNCTIONS))
256
300
  return [ false, "Blocked: dangerous function #{m[0]} (filesystem/network primitive)" ]
257
301
  end
258
302
 
259
- cleaned = strip_sql_comments(sql)
303
+ cleaned = strip_sql_comments(sql, mysql: mysql)
260
304
 
261
305
  # Check multi-statement and clause patterns first - they provide more
262
306
  # specific error messages than the generic keyword blocker.
@@ -332,7 +376,7 @@ module RailsAiContext
332
376
 
333
377
  limited_sql = apply_row_limit(sql, row_limit)
334
378
 
335
- run_guarded(conn, adapter, limited_sql, timeout_seconds)
379
+ cap_rows(run_guarded(conn, adapter, limited_sql, timeout_seconds), sql, row_limit)
336
380
  end
337
381
 
338
382
  # EXPLAIN goes through the adapter wrappers too (READ ONLY, timeout): EXPLAIN ANALYZE runs
@@ -364,7 +408,7 @@ module RailsAiContext
364
408
 
365
409
  private_class_method def self.execute_mysql(conn, sql, timeout)
366
410
  # Inject MAX_EXECUTION_TIME hint for per-query timeout
367
- hinted_sql = if sql.match?(/\ASELECT/i) && !sql.match?(/\/\*\+/)
411
+ hinted_sql = if sql.match?(/\ASELECT/i)
368
412
  sql.sub(/\ASELECT/i, "SELECT /*+ MAX_EXECUTION_TIME(#{(timeout * 1000).to_i}) */")
369
413
  else
370
414
  sql
@@ -499,8 +543,7 @@ module RailsAiContext
499
543
 
500
544
  # ── EXPLAIN execution ────────────────────────────────────────────
501
545
  private_class_method def self.execute_explain(sql, timeout)
502
- cleaned = strip_sql_comments(sql)
503
- unless cleaned.match?(/\A\s*(SELECT|WITH)\b/i)
546
+ unless sql.match?(/\A\s*(SELECT|WITH)\b/i)
504
547
  return text_response("EXPLAIN only supports SELECT queries.")
505
548
  end
506
549
 
@@ -659,26 +702,51 @@ module RailsAiContext
659
702
  end
660
703
 
661
704
  # ── Row limit enforcement (Layer 3) ─────────────────────────────
705
+ # The query's own limit ends the statement. A LIMIT anywhere else belongs
706
+ # to a subquery or a string literal and is left as written.
707
+ TRAILING_LIMIT = /\bLIMIT\s+(?:(\d+)\s*,\s*)?(\d+)(\s+OFFSET\s+\d+)?\s*;?\s*\z/i
708
+ TRAILING_FETCH = /\bFETCH\s+(FIRST|NEXT)\s+(\d+)(\s+ROWS?\s+ONLY)\s*;?\s*\z/i
709
+
662
710
  private_class_method def self.apply_row_limit(sql, limit)
663
711
  return sql if sql.match?(SCHEMA_METADATA_PREFIX)
664
712
 
665
- effective_limit = [ limit, HARD_ROW_CAP ].min
713
+ cap = [ limit, HARD_ROW_CAP ].min
666
714
 
667
- if sql.match?(/\bLIMIT\s+(\d+)/i)
668
- sql.sub(/\bLIMIT\s+(\d+)/i) do
669
- user_limit = $1.to_i
670
- "LIMIT #{[ user_limit, effective_limit ].min}"
671
- end
672
- elsif sql.match?(/\bFETCH\s+FIRST\s+(\d+)/i)
673
- sql.sub(/\bFETCH\s+FIRST\s+(\d+)/i) do
674
- user_limit = $1.to_i
675
- "FETCH FIRST #{[ user_limit, effective_limit ].min}"
676
- end
715
+ if sql.match?(TRAILING_LIMIT)
716
+ sql.sub(TRAILING_LIMIT) { "LIMIT #{"#{$1}, " if $1}#{[ $2.to_i, cap ].min}#{$3}" }
717
+ elsif sql.match?(TRAILING_FETCH)
718
+ sql.sub(TRAILING_FETCH) { "FETCH #{$1} #{[ $2.to_i, cap ].min}#{$3}" }
719
+ elsif own_limit?(sql)
720
+ # A limit in a spelling this does not rewrite (`LIMIT 1+1`, `WITH TIES`).
721
+ # A second one would be a syntax error, so it runs as written and cap_rows holds the cap.
722
+ sql
677
723
  else
678
- "#{sql.chomp.chomp(';')} LIMIT #{effective_limit}"
724
+ # On its own line, so nothing the database reads as a line comment takes it.
725
+ "#{sql.sub(/;\s*\z/, "")}\nLIMIT #{cap}"
679
726
  end
680
727
  end
681
728
 
729
+ # Whether the last LIMIT or FETCH is the statement's own: nothing after
730
+ # it closes a parenthesis or a quote that opened before it.
731
+ private_class_method def self.own_limit?(sql)
732
+ start = sql.rindex(/\b(?:LIMIT|FETCH\s+(?:FIRST|NEXT))\b/i) or return false
733
+ tail = sql[start..]
734
+ depth = 0
735
+ tail.each_char do |char|
736
+ depth += { "(" => 1, ")" => -1 }.fetch(char, 0)
737
+ return false if depth.negative?
738
+ end
739
+ [ "'", '"', "`" ].all? { |quote| tail.count(quote).even? }
740
+ end
741
+
742
+ # The text carries the limit. This holds it whatever the database made of that text.
743
+ private_class_method def self.cap_rows(result, sql, limit)
744
+ cap = [ limit, HARD_ROW_CAP ].min
745
+ return result if sql.match?(SCHEMA_METADATA_PREFIX) || result.rows.size <= cap
746
+
747
+ ResultProxy.new(result.columns, result.rows.first(cap), result.respond_to?(:unbounded) && result.unbounded)
748
+ end
749
+
682
750
  # ── Column redaction (Layer 4) ──────────────────────────────────
683
751
  private_class_method def self.redact_results(result)
684
752
  allowed = allowed_columns
@@ -701,7 +769,7 @@ module RailsAiContext
701
769
  col_down = col.downcase
702
770
  i if encrypted_cols.include?(col_down) || redacted_cols.include?(col_down) ||
703
771
  (!allowed.include?(col_down) &&
704
- sensitive_suffixes.any? { |suffix| col_down.end_with?(suffix) || col_down.include?("password") || col_down.include?("secret") || col_down.include?("token") })
772
+ (col_down.end_with?(*sensitive_suffixes) || col_down.match?(/password|secret|token/)))
705
773
  }
706
774
 
707
775
  return result if redacted_indices.empty?
@@ -684,8 +684,7 @@ module RailsAiContext
684
684
  # below reads, and `match?` sets none.
685
685
  controller_match = file.match(%r{app/controllers/(.+)_controller\.rb})
686
686
  if category == "Controller" && controller_match
687
- route_actions = extract_controller_actions_from_matches(matches)
688
- routes = find_routes_for_controller(controller_match[1], route_actions, root, ctx)
687
+ routes = find_routes_for_controller(controller_match[1], enclosing_actions(file, matches, root), ctx)
689
688
  route_hint = " → #{routes}" if routes
690
689
  end
691
690
 
@@ -759,24 +758,29 @@ module RailsAiContext
759
758
  end
760
759
 
761
760
  # Extract which action a controller caller is in
762
- private_class_method def self.extract_controller_actions_from_matches(matches)
763
- actions = []
764
- matches.each do |m|
765
- # Match standard RESTful action names from the content
766
- if (match = m[:content].match(/\b(index|show|new|create|edit|update|destroy)\b/))
767
- actions << match[1]
768
- end
769
- end
770
- actions.uniq.first(3)
761
+ # The actions a controller file's matches sit in: the public instance
762
+ # methods of the first class it declares. A call from a macro line, a
763
+ # private method, a class method, a module or a nested class sits in none.
764
+ private_class_method def self.enclosing_actions(file, matches, root)
765
+ source = RailsAiContext::SafeFile.read(File.join(root, file)) || ""
766
+ controller = Introspectors::DeclaredConstant.declared_names(source).first or return []
767
+ actions = Introspectors::ActionResolver.own_methods_in(source, controller).select { |d|
768
+ d[:end_location] && d[:scope] == :instance && d[:visibility] == :public
769
+ }
770
+ matches.filter_map { |m|
771
+ actions.find { |d| (d[:location]..d[:end_location]).cover?(m[:line_number]) }&.dig(:name)
772
+ }.uniq
773
+ rescue => e
774
+ RailsAiContext.debug_fail(e, [], label: "enclosing_actions")
771
775
  end
772
776
 
773
- # Find routes for a controller
774
- private_class_method def self.find_routes_for_controller(ctrl_path, _actions, _root, ctx)
777
+ # Routes of the actions the callers sit in, so the hint never names a
778
+ # route that does not reach the call.
779
+ private_class_method def self.find_routes_for_controller(ctrl_path, actions, ctx)
775
780
  routes = ctx[:routes]
776
781
  return nil unless routes
777
- ctrl_routes = RouteCoverage.all_by_controller(routes)[ctrl_path]
778
- return nil unless ctrl_routes&.any?
779
- # Show the first 2 routes as hints
782
+ ctrl_routes = Array(RouteCoverage.all_by_controller(routes)[ctrl_path]).select { |r| actions.include?(r[:action].to_s) }
783
+ return nil unless ctrl_routes.any?
780
784
  ctrl_routes.first(2).map { |r| "`#{r[:verb]} #{r[:path]}`" }.join(", ")
781
785
  rescue => e
782
786
  RailsAiContext.debug_fail(e, nil, label: "find_routes_for_controller")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.32.1"
4
+ VERSION = "5.32.2"
5
5
  end