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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +74 -0
- data/CONTEXT.md +10 -8
- data/CONTRIBUTING.md +2 -2
- data/README.md +14 -11
- data/SECURITY.md +4 -4
- data/docs/ARCHITECTURE.md +11 -10
- data/docs/CLI.md +33 -7
- data/docs/COMPATIBILITY.md +48 -32
- data/docs/CONFIGURATION.md +10 -8
- data/docs/CUSTOM_TOOLS.md +8 -8
- data/docs/FAQ.md +6 -5
- data/docs/GUIDE.md +154 -80
- data/docs/INTROSPECTORS.md +24 -16
- data/docs/QUICKSTART.md +14 -3
- data/docs/RECIPES.md +50 -40
- data/docs/SECURITY.md +23 -13
- data/docs/SETUP.md +23 -26
- data/docs/STANDALONE.md +7 -3
- data/docs/TOOLS.md +79 -45
- data/docs/TROUBLESHOOTING.md +12 -11
- data/exe/rails-ai-context +8 -4
- data/lib/generators/rails_ai_context/install/install_generator.rb +1 -1
- data/lib/rails_ai_context/configuration.rb +2 -2
- data/lib/rails_ai_context/install/ai_tool.rb +11 -2
- data/lib/rails_ai_context/install/cleanup.rb +69 -23
- data/lib/rails_ai_context/install/program.rb +4 -1
- data/lib/rails_ai_context/install/selection_record.rb +20 -2
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +2 -2
- data/lib/rails_ai_context/server.rb +2 -2
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +12 -4
- data/lib/rails_ai_context/test_helper.rb +2 -0
- data/lib/rails_ai_context/tools/base_tool.rb +3 -1
- data/lib/rails_ai_context/tools/dependency_graph.rb +2 -2
- data/lib/rails_ai_context/tools/get_concern.rb +1 -1
- data/lib/rails_ai_context/tools/get_env.rb +1 -1
- data/lib/rails_ai_context/tools/get_gems.rb +2 -2
- data/lib/rails_ai_context/tools/query.rb +107 -39
- data/lib/rails_ai_context/tools/search_code.rb +20 -16
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +1 -1
- 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] }
|
|
19
|
-
#
|
|
20
|
-
#
|
|
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
|
-
|
|
25
|
-
failed = []
|
|
37
|
+
outcome = { removed: [], trimmed: [], kept: [], failed: [] }
|
|
26
38
|
|
|
27
39
|
Array(tools).each do |key|
|
|
28
|
-
|
|
29
|
-
|
|
40
|
+
tool = AiTool.find(key) or next
|
|
41
|
+
tool.context_paths.each do |relative|
|
|
30
42
|
next if kept.include?(relative)
|
|
31
43
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
52
|
+
outcome
|
|
40
53
|
end
|
|
41
54
|
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
-
">
|
|
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
|
-
">
|
|
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`,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
713
|
+
cap = [ limit, HARD_ROW_CAP ].min
|
|
666
714
|
|
|
667
|
-
if sql.match?(
|
|
668
|
-
sql.sub(
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
#
|
|
774
|
-
|
|
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
|
|
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")
|